diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dc01c3400..90303624e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -55,7 +55,7 @@ jobs:
# Only collect code coverage with aiida-core=2.3, to speed up tests
# with higher aiida versions that for some reason run slower, see:
# https://github.com/aiidalab/aiidalab-qe/issues/766
- run: pytest -v tests ${{ matrix.aiida-core-version == '2.3' && '--cov=aiidalab_qe' || '' }}
+ run: pytest -v tests ${{ matrix.aiida-core-version == '2.3' && '--cov=aiidalab_qe' || '' }} --skip-slow
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4
diff --git a/qe.ipynb b/qe.ipynb
index 0ec99c175..6f3e977e0 100644
--- a/qe.ipynb
+++ b/qe.ipynb
@@ -73,8 +73,6 @@
"metadata": {},
"outputs": [],
"source": [
- "import urllib.parse as urlparse\n",
- "\n",
"from aiidalab_qe.app.main import App\n",
"from aiidalab_widgets_base.bug_report import (\n",
" install_create_github_issue_exception_handler,\n",
@@ -86,17 +84,23 @@
" labels=(\"bug\", \"automated-report\"),\n",
")\n",
"\n",
+ "app = App(qe_auto_setup=True)\n",
+ "\n",
+ "view.main.children = [app]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import urllib.parse as urlparse\n",
+ "\n",
"url = urlparse.urlsplit(jupyter_notebook_url) # noqa F821\n",
"query = urlparse.parse_qs(url.query)\n",
- "\n",
- "app = App(qe_auto_setup=True)\n",
- "# if a pk is provided in the query string, set it as the process of the app\n",
"if \"pk\" in query:\n",
- " pk = query[\"pk\"][0]\n",
- " app.process = pk\n",
- "\n",
- "view.main.children = [app]\n",
- "view.app = app"
+ " app.process = query[\"pk\"][0]"
]
},
{
@@ -111,7 +115,7 @@
],
"metadata": {
"kernelspec": {
- "display_name": "Python 3 (ipykernel)",
+ "display_name": "base",
"language": "python",
"name": "python3"
},
@@ -126,11 +130,6 @@
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.9.13"
- },
- "vscode": {
- "interpreter": {
- "hash": "d4d1e4263499bec80672ea0156c357c1ee493ec2b1c70f0acce89fc37c4a6abe"
- }
}
},
"nbformat": 4,
diff --git a/setup.cfg b/setup.cfg
index e3d9a86ed..0c68fb705 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -57,8 +57,8 @@ aiidalab_qe.plugins.xas = pseudo_toc.yaml
aiidalab_qe.properties =
bands = aiidalab_qe.plugins.bands:bands
pdos = aiidalab_qe.plugins.pdos:pdos
- xps = aiidalab_qe.plugins.xps:xps
electronic_structure = aiidalab_qe.plugins.electronic_structure:electronic_structure
+ xps = aiidalab_qe.plugins.xps:xps
xas = aiidalab_qe.plugins.xas:xas
aiida.workflows =
diff --git a/src/aiidalab_qe/app/configuration/__init__.py b/src/aiidalab_qe/app/configuration/__init__.py
index 39f5f07ae..2d10f0c18 100644
--- a/src/aiidalab_qe/app/configuration/__init__.py
+++ b/src/aiidalab_qe/app/configuration/__init__.py
@@ -8,91 +8,114 @@
import ipywidgets as ipw
import traitlets as tl
-from aiida import orm
+from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS
from aiidalab_qe.app.utils import get_entry_items
+from aiidalab_qe.common.panel import SettingsModel, SettingsPanel
from aiidalab_widgets_base import WizardAppWidgetStep
-from .advanced import AdvancedSettings
-from .workflow import WorkChainSettings
+from .advanced import AdvancedModel, AdvancedSettings
+from .basic import WorkChainModel, WorkChainSettings
+from .model import ConfigurationStepModel
+
+DEFAULT: dict = DEFAULT_PARAMETERS # type: ignore
class ConfigureQeAppWorkChainStep(ipw.VBox, WizardAppWidgetStep):
- confirmed = tl.Bool()
previous_step_state = tl.UseEnum(WizardAppWidgetStep.State)
- input_structure = tl.Instance(orm.StructureData, allow_none=True)
-
- # output dictionary
- configuration_parameters = tl.Dict()
- def __init__(self, **kwargs):
- self.workchain_settings = WorkChainSettings()
- self.advanced_settings = AdvancedSettings()
+ def __init__(self, model: ConfigurationStepModel, **kwargs):
+ from aiidalab_qe.common.widgets import LoadingWidget
- ipw.dlink(
- (self.workchain_settings.workchain_protocol, "value"),
- (self.advanced_settings, "protocol"),
- )
- ipw.dlink(
- (self.workchain_settings.spin_type, "value"),
- (self.advanced_settings, "spin_type"),
+ super().__init__(
+ children=[LoadingWidget("Loading workflow configuration step")],
+ **kwargs,
)
- ipw.dlink(
- (self.workchain_settings.electronic_type, "value"),
- (self.advanced_settings, "electronic_type"),
+
+ self._model = model
+ self._model.observe(
+ self._on_confirmation_change,
+ "confirmed",
)
- ipw.dlink(
- (self, "input_structure"),
- (self.advanced_settings, "input_structure"),
+ self._model.observe(
+ self._on_input_structure_change,
+ "input_structure",
)
- #
+
+ self.rendered = False
+
+ self.structure_set_message = ipw.HTML()
ipw.dlink(
- (self, "input_structure"),
- (self.workchain_settings, "input_structure"),
- )
- #
- self.built_in_settings = [
- self.workchain_settings,
- self.advanced_settings,
- ]
- self.tab = ipw.Tab(
- children=self.built_in_settings,
- layout=ipw.Layout(min_height="250px"),
+ (self._model, "input_structure"),
+ (self.structure_set_message, "value"),
+ lambda structure: ""
+ if structure
+ else """
+
+ Please set the input structure first.
+
+ """,
)
- self.tab.set_title(0, "Basic settings")
- self.tab.set_title(1, "Advanced settings")
+ workchain_model = WorkChainModel()
+ self.workchain_settings = WorkChainSettings(model=workchain_model)
+ self._model.add_model("workchain", workchain_model)
+
+ advanced_model = AdvancedModel()
+ self.advanced_settings = AdvancedSettings(model=advanced_model)
+ self._model.add_model("advanced", advanced_model)
- # store the property identifier and setting panel for all plugins
- # only show the setting panel when the corresponding property is selected
- # first add the built-in settings
self.settings = {
"workchain": self.workchain_settings,
"advanced": self.advanced_settings,
}
- # list of trailets to link
- # if new trailets are added to the settings, they need to be added here
- trailets_list = ["input_structure", "protocol", "electronic_type", "spin_type"]
+ self.property_children = []
- # then add plugin specific settings
- entries = get_entry_items("aiidalab_qe.properties", "setting")
- for identifier, entry_point in entries.items():
- self.settings[identifier] = entry_point(parent=self)
- self.settings[identifier].identifier = identifier
- # link basic protocol to all plugin specific protocols
- if identifier in self.workchain_settings.properties:
- self.workchain_settings.properties[identifier].run.observe(
- self._update_panel, "value"
- )
- # link the trailets if they exist in the plugin specific settings
- for trailet in trailets_list:
- if hasattr(self.settings[identifier], trailet):
- ipw.dlink(
- (self.advanced_settings, trailet),
- (self.settings[identifier], trailet),
- )
+ self._fetch_plugin_settings()
+
+ def render(self):
+ if self.rendered:
+ return
- self._submission_blocker_messages = ipw.HTML()
+ # RelaxType: degrees of freedom in geometry optimization
+ self.relax_type_help = ipw.HTML()
+ ipw.dlink(
+ (self._model, "relax_type_help"),
+ (self.relax_type_help, "value"),
+ )
+ self.relax_type = ipw.ToggleButtons()
+ ipw.dlink(
+ (self._model, "relax_type_options"),
+ (self.relax_type, "options"),
+ )
+ ipw.link(
+ (self._model, "relax_type"),
+ (self.relax_type, "value"),
+ )
+
+ self.tabs = ipw.Tab(
+ layout=ipw.Layout(min_height="250px"),
+ selected_index=None,
+ )
+ self.tabs.observe(
+ self._on_tab_change,
+ "selected_index",
+ )
+
+ self.sub_steps = ipw.Accordion(
+ children=[
+ ipw.VBox(
+ children=[
+ *self.property_children,
+ ]
+ ),
+ self.tabs,
+ ],
+ layout=ipw.Layout(margin="10px 2px"),
+ selected_index=None,
+ )
+ self.sub_steps.set_title(0, "Step 2.1: Select which properties to calculate")
+ self.sub_steps.set_title(1, "Step 2.2: Customize calculation parameters")
self.confirm_button = ipw.Button(
description="Confirm",
@@ -102,84 +125,145 @@ def __init__(self, **kwargs):
disabled=True,
layout=ipw.Layout(width="auto"),
)
-
+ ipw.dlink(
+ (self, "state"),
+ (self.confirm_button, "disabled"),
+ lambda state: state != self.State.CONFIGURED,
+ )
self.confirm_button.on_click(self.confirm)
- super().__init__(
- children=[
- self.tab,
- self._submission_blocker_messages,
- self.confirm_button,
- ],
- **kwargs,
- )
+ self.children = [
+ self.structure_set_message,
+ ipw.HTML("""
+
+
Structure relaxation
+
+ """),
+ self.relax_type_help,
+ self.relax_type,
+ self.sub_steps,
+ self.confirm_button,
+ ]
+
+ self.rendered = True
+
+ self._update_tabs()
+
+ if self._model.confirmed: # loaded from a process
+ return
+
+ # NOTE technically not necessary, as an update is triggered
+ # by a structure change. However, this ensures that if a user
+ # decides to visit this step prior to setting the structure,
+ # the step will be updated on render to show reasonable defaults.
+ # TODO remove if we decide to "disable" steps past unconfirmed steps!
+ self._model.update()
+
+ def is_saved(self):
+ return self._model.confirmed
+
+ def confirm(self, _=None):
+ self._model.confirm()
+
+ def reset(self):
+ self._model.reset()
+ if self.rendered:
+ self.sub_steps.selected_index = None
+ self.tabs.selected_index = 0
@tl.observe("previous_step_state")
- def _observe_previous_step_state(self, _change):
+ def _on_previous_step_state_change(self, _):
self._update_state()
- def get_configuration_parameters(self):
- """Get the parameters of the configuration step."""
+ def _on_tab_change(self, change):
+ if (tab_index := change["new"]) is None:
+ return
+ tab: SettingsPanel = self.tabs.children[tab_index] # type: ignore
+ tab.render()
+ tab.update()
- return {s.identifier: s.get_panel_value() for s in self.tab.children}
+ def _on_input_structure_change(self, _):
+ self._model.update()
+ self.reset()
- def set_configuration_parameters(self, parameters):
- """Set the inputs in the GUI based on a set of parameters."""
+ def _on_confirmation_change(self, _):
+ self._update_state()
- with self.hold_trait_notifications():
- for identifier, settings in self.settings.items():
- if parameters.get(identifier):
- settings.set_panel_value(parameters[identifier])
+ def _update_tabs(self):
+ children = []
+ titles = []
+ for identifier, model in self._model.get_models():
+ if model.include:
+ settings = self.settings[identifier]
+ titles.append(settings.title)
+ children.append(settings)
+ if self.rendered:
+ self.tabs.selected_index = None
+ self.tabs.children = children
+ for i, title in enumerate(titles):
+ self.tabs.set_title(i, title)
+ self.tabs.selected_index = 0
def _update_state(self, _=None):
- if self.previous_step_state == self.State.SUCCESS:
- self.confirm_button.disabled = False
- self._submission_blocker_messages.value = ""
+ if self._model.confirmed:
+ self.state = self.State.SUCCESS
+ elif self.previous_step_state is self.State.SUCCESS:
self.state = self.State.CONFIGURED
- # update plugin specific settings
- for _, settings in self.settings.items():
- settings._update_state()
- elif self.previous_step_state == self.State.FAIL:
+ elif self.previous_step_state is self.State.FAIL:
self.state = self.State.FAIL
else:
- self.confirm_button.disabled = True
self.state = self.State.INIT
- self.reset()
- def confirm(self, _=None):
- self.configuration_parameters = self.get_configuration_parameters()
- self.confirm_button.disabled = False
- self.state = self.State.SUCCESS
+ def _fetch_plugin_settings(self):
+ outlines = get_entry_items("aiidalab_qe.properties", "outline")
+ entries = get_entry_items("aiidalab_qe.properties", "setting")
+ for identifier, entry in entries.items():
+ for key in ("panel", "model"):
+ if key not in entry:
+ raise ValueError(f"Entry {identifier} is missing the '{key}' key")
+ panel = entry["panel"]
+ model: SettingsModel = entry["model"]()
+ self._model.add_model(identifier, model)
- def is_saved(self):
- """Check if the current step is saved.
- That all changes are confirmed.
- """
- new_parameters = self.get_configuration_parameters()
- return new_parameters == self.configuration_parameters
+ outline = outlines[identifier]()
+ info = ipw.HTML()
+ ipw.link(
+ (model, "include"),
+ (outline.include, "value"),
+ )
- @tl.default("state")
- def _default_state(self):
- return self.State.INIT
+ if identifier == "bands":
+ ipw.dlink(
+ (self._model, "input_structure"),
+ (outline.include, "disabled"),
+ lambda _: not self._model.has_pbc,
+ )
- def reset(self):
- """Reset the widgets in all settings to their initial states."""
- with self.hold_trait_notifications():
- self.input_structure = None
- for _, settings in self.settings.items():
- settings.reset()
-
- def _update_panel(self, _=None):
- """Dynamic add/remove the panel based on the selected properties."""
- # only keep basic and advanced settings
- self.tab.children = self.built_in_settings
- # add plugin specific settings
- for identifier in self.workchain_settings.properties:
- if (
- identifier in self.settings
- and self.workchain_settings.properties[identifier].run.value
- ):
- self.tab.children += (self.settings[identifier],)
- self.tab.set_title(
- len(self.tab.children) - 1, self.settings[identifier].title
+ def toggle_plugin(change, identifier=identifier, model=model, info=info):
+ if change["new"]:
+ info.value = (
+ f"Customize {identifier} settings in Step 2.2 if needed"
+ )
+ else:
+ info.value = ""
+ model.update()
+ self._update_tabs()
+
+ model.observe(
+ toggle_plugin,
+ "include",
+ )
+
+ self.property_children.append(
+ ipw.HBox(
+ children=[
+ outline,
+ info,
+ ]
)
+ )
+
+ self.settings[identifier] = panel(
+ identifier=identifier,
+ model=model,
+ )
diff --git a/src/aiidalab_qe/app/configuration/advanced.py b/src/aiidalab_qe/app/configuration/advanced.py
deleted file mode 100644
index 5df0e76cd..000000000
--- a/src/aiidalab_qe/app/configuration/advanced.py
+++ /dev/null
@@ -1,942 +0,0 @@
-"""Widgets for the submission of bands work chains.
-
-Authors: AiiDAlab team
-"""
-
-import os
-
-import ipywidgets as ipw
-import numpy as np
-import traitlets as tl
-from IPython.display import clear_output, display
-
-from aiida import orm
-from aiida_quantumespresso.calculations.functions.create_kpoints_from_distance import (
- create_kpoints_from_distance,
-)
-from aiida_quantumespresso.data.hubbard_structure import HubbardStructureData
-from aiida_quantumespresso.workflows.pw.base import PwBaseWorkChain
-from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS
-from aiidalab_qe.common.panel import Panel
-from aiidalab_qe.common.widgets import HubbardWidget
-from aiidalab_qe.setup.pseudos import PseudoFamily
-
-from .pseudos import PseudoFamilySelector, PseudoSetter
-
-
-class AdvancedSettings(Panel):
- identifier = "advanced"
-
- title = ipw.HTML(
- """
-
Advanced Settings
"""
- )
- pw_adv_description = ipw.HTML(
- """Select the advanced settings for the pw.x code."""
- )
- kpoints_description = ipw.HTML(
- """
- The k-points mesh density of the SCF calculation is set by the protocol.
- The value below represents the maximum distance between the k-points in each direction of reciprocal space.
- Tick the box to override the default, smaller is more accurate and costly.
"""
- )
-
- dftd3_version = {
- "dft-d3": 3,
- "dft-d3bj": 4,
- "dft-d3m": 5,
- "dft-d3mbj": 6,
- }
- # protocol interface
- protocol = tl.Unicode(allow_none=True)
- input_structure = tl.Instance(orm.StructureData, allow_none=True)
- spin_type = tl.Unicode()
- electronic_type = tl.Unicode()
-
- # output dictionary
- value = tl.Dict()
-
- def __init__(self, default_protocol=None, **kwargs):
- self._default_protocol = (
- default_protocol or DEFAULT_PARAMETERS["workchain"]["protocol"]
- )
-
- # clean-up workchain settings
- self.clean_workdir = ipw.Checkbox(
- description="",
- indent=False,
- value=False,
- layout=ipw.Layout(max_width="20px"),
- )
- self.clean_workdir_description = ipw.HTML(
- """
- Tick to clean-up the work directory after the calculation is finished.
"""
- )
-
- # Override setting widget
- self.override_prompt = ipw.HTML(" Override ")
- self.override = ipw.Checkbox(
- description="",
- indent=False,
- value=False,
- layout=ipw.Layout(max_width="10%"),
- )
- self.override.observe(self._override_changed, "value")
-
- self.override_widget = ipw.HBox(
- [self.override_prompt, self.override],
- layout=ipw.Layout(max_width="20%"),
- )
- # Smearing setting widget
- self.smearing = SmearingSettings()
- ipw.dlink(
- (self.override, "value"),
- (self.smearing, "disabled"),
- lambda override: not override,
- )
- self.smearing.observe(
- self._callback_value_set, ["degauss_value", "smearing_value"]
- )
-
- # Kpoints setting widget
- self.kpoints_distance = ipw.BoundedFloatText(
- min=0.0,
- step=0.05,
- description="K-points distance (1/Å):",
- disabled=False,
- style={"description_width": "initial"},
- )
- self.mesh_grid = ipw.HTML()
- self.create_kpoints_distance_link()
- self.kpoints_distance.observe(self._callback_value_set, "value")
-
- # Hubbard setting widget
- self.hubbard_widget = HubbardWidget()
- ipw.dlink(
- (self.override, "value"),
- (self.hubbard_widget.activate_hubbard, "disabled"),
- lambda override: not override,
- )
- # Total change setting widget
- self.total_charge = ipw.BoundedFloatText(
- min=-3,
- max=3,
- step=0.01,
- disabled=False,
- description="Total charge:",
- style={"description_width": "initial"},
- )
- ipw.dlink(
- (self.override, "value"),
- (self.total_charge, "disabled"),
- lambda override: not override,
- )
- self.total_charge.observe(self._callback_value_set, "value")
-
- # Van der Waals setting widget
- self.van_der_waals = ipw.Dropdown(
- options=[
- ("None", "none"),
- ("Grimme-D3", "dft-d3"),
- ("Grimme-D3BJ", "dft-d3bj"),
- ("Grimme-D3M", "dft-d3m"),
- ("Grimme-D3MBJ", "dft-d3mbj"),
- ("Tkatchenko-Scheffler", "ts-vdw"),
- ],
- description="Van der Waals correction:",
- value="none",
- disabled=False,
- style={"description_width": "initial"},
- )
-
- ipw.dlink(
- (self.override, "value"),
- (self.van_der_waals, "disabled"),
- lambda override: not override,
- )
-
- self.magnetization = MagnetizationSettings()
- ipw.dlink(
- (self.override, "value"),
- (self.magnetization, "disabled"),
- lambda override: not override,
- )
-
- # Convergence Threshold settings
- self.scf_conv_thr = ipw.BoundedFloatText(
- min=1e-15,
- max=1.0,
- step=1e-10,
- description="SCF conv.:",
- disabled=False,
- style={"description_width": "initial"},
- )
- self.scf_conv_thr.observe(self._callback_value_set, "value")
- ipw.dlink(
- (self.override, "value"),
- (self.scf_conv_thr, "disabled"),
- lambda override: not override,
- )
- self.forc_conv_thr = ipw.BoundedFloatText(
- min=1e-15,
- max=1.0,
- step=0.0001,
- description="Force conv.:",
- disabled=False,
- style={"description_width": "initial"},
- )
- self.forc_conv_thr.observe(self._callback_value_set, "value")
- ipw.dlink(
- (self.override, "value"),
- (self.forc_conv_thr, "disabled"),
- lambda override: not override,
- )
- self.etot_conv_thr = ipw.BoundedFloatText(
- min=1e-15,
- max=1.0,
- step=0.00001,
- description="Energy conv.:",
- disabled=False,
- style={"description_width": "initial"},
- )
- self.etot_conv_thr.observe(self._callback_value_set, "value")
- ipw.dlink(
- (self.override, "value"),
- (self.etot_conv_thr, "disabled"),
- lambda override: not override,
- )
-
- # Max electron SCF steps widget
- self._create_electron_maxstep_widgets()
-
- # Spin-Orbit calculation
- self.spin_orbit = ipw.ToggleButtons(
- options=[
- ("Off", "wo_soc"),
- ("On", "soc"),
- ],
- description="Spin-Orbit:",
- value="wo_soc",
- style={"description_width": "initial"},
- )
- ipw.dlink(
- (self.override, "value"),
- (self.spin_orbit, "disabled"),
- lambda override: not override,
- )
-
- self.pseudo_family_selector = PseudoFamilySelector()
- self.pseudo_setter = PseudoSetter()
- ipw.dlink(
- (self.pseudo_family_selector, "value"),
- (self.pseudo_setter, "pseudo_family"),
- )
- self.kpoints_distance.observe(self._display_mesh, "value")
-
- # Link with PseudoWidget
- ipw.dlink(
- (self.spin_orbit, "value"),
- (self.pseudo_family_selector, "spin_orbit"),
- )
- self.children = [
- self.title,
- ipw.HBox(
- [self.clean_workdir, self.clean_workdir_description],
- layout=ipw.Layout(height="50px", justify_content="flex-start"),
- ),
- ipw.HBox(
- [self.pw_adv_description, self.override_widget],
- layout=ipw.Layout(height="50px", justify_content="space-between"),
- ),
- # total charge setting widget
- self.total_charge,
- # van der waals setting widget
- self.van_der_waals,
- # magnetization setting widget
- self.magnetization,
- # convergence threshold setting widget
- ipw.HTML("Convergence Thresholds:"),
- ipw.HBox(
- [self.forc_conv_thr, self.etot_conv_thr, self.scf_conv_thr],
- layout=ipw.Layout(height="50px", justify_content="flex-start"),
- ),
- # Max electron SCF steps widget
- self.electron_maxstep,
- # smearing setting widget
- self.smearing,
- # Kpoints setting widget
- self.kpoints_description,
- ipw.HBox([self.kpoints_distance, self.mesh_grid]),
- self.hubbard_widget,
- # Spin-Orbit calculation
- self.spin_orbit,
- self.pseudo_family_selector,
- self.pseudo_setter,
- ]
- super().__init__(
- layout=ipw.Layout(justify_content="space-between"),
- **kwargs,
- )
-
- # Default settings to trigger the callback
- self.reset()
-
- def create_kpoints_distance_link(self):
- """Create the dlink for override and kpoints_distance."""
- self.kpoints_distance_link = ipw.dlink(
- (self.override, "value"),
- (self.kpoints_distance, "disabled"),
- lambda override: not override,
- )
-
- def remove_kpoints_distance_link(self):
- """Remove the kpoints_distance_link."""
- if hasattr(self, "kpoints_distance_link"):
- self.kpoints_distance_link.unlink()
- del self.kpoints_distance_link
-
- def _create_electron_maxstep_widgets(self):
- self.electron_maxstep = ipw.BoundedIntText(
- min=20,
- max=1000,
- step=1,
- value=80,
- description="Max. electron steps:",
- style={"description_width": "initial"},
- )
- ipw.dlink(
- (self.override, "value"),
- (self.electron_maxstep, "disabled"),
- lambda override: not override,
- )
- self.electron_maxstep.observe(self._callback_value_set, "value")
-
- def set_value_and_step(self, attribute, value):
- """
- Sets the value and adjusts the step based on the order of magnitude of the value.
- This is used for the thresolds values (etot_conv_thr, scf_conv_thr, forc_conv_thr).
- Parameters:
- attribute: The attribute whose values are to be set (e.g., self.etot_conv_thr).
- value: The numerical value to set.
- """
- attribute.value = value
- if value != 0:
- order_of_magnitude = np.floor(np.log10(abs(value)))
- attribute.step = 10 ** (order_of_magnitude - 1)
- else:
- attribute.step = 0.1 # Default step if value is zero
-
- def _override_changed(self, change):
- """Callback function to set the override value"""
- if change["new"] is False:
- # When override is set to False, reset the widget
- self.reset()
-
- @tl.observe("input_structure")
- def _update_input_structure(self, change):
- if self.input_structure is not None:
- self.magnetization._update_widget(change)
- self.pseudo_setter.structure = change["new"]
- self._update_settings_from_protocol(self.protocol)
- self._display_mesh()
- self.hubbard_widget.update_widgets(change["new"])
- if isinstance(self.input_structure, HubbardStructureData):
- self.override.value = True
- if self.input_structure.pbc == (False, False, False):
- self.kpoints_distance.value = 100.0
- self.kpoints_distance.disabled = True
- if hasattr(self, "kpoints_distance_link"):
- self.remove_kpoints_distance_link()
- else:
- # self.kpoints_distance.disabled = False
- if not hasattr(self, "kpoints_distance_link"):
- self.create_kpoints_distance_link()
- else:
- self.magnetization.input_structure = None
- self.pseudo_setter.structure = None
- self.hubbard_widget.update_widgets(None)
- self.kpoints_distance.disabled = False
- if not hasattr(self, "kpoints_distance_link"):
- self.create_kpoints_distance_link()
-
- @tl.observe("electronic_type")
- def _electronic_type_changed(self, change):
- """Input electronic_type changed, update the widget values."""
- self.magnetization.electronic_type = change["new"]
-
- @tl.observe("protocol")
- def _protocol_changed(self, _):
- """Input protocol changed, update the widget values."""
- self._update_settings_from_protocol(self.protocol)
-
- def _update_settings_from_protocol(self, protocol):
- """Update the values of sub-widgets from the given protocol, this will
- trigger the callback of the sub-widget if it is exist.
- """
- self.smearing.protocol = protocol
- self.pseudo_family_selector.protocol = protocol
-
- parameters = PwBaseWorkChain.get_protocol_inputs(protocol)
-
- if self.input_structure:
- if self.input_structure.pbc == (False, False, False):
- self.kpoints_distance.value = 100.0
- self.kpoints_distance.disabled = True
- else:
- self.kpoints_distance.value = parameters["kpoints_distance"]
- else:
- self.kpoints_distance.value = parameters["kpoints_distance"]
-
- num_atoms = len(self.input_structure.sites) if self.input_structure else 1
-
- etot_value = num_atoms * parameters["meta_parameters"]["etot_conv_thr_per_atom"]
- self.set_value_and_step(self.etot_conv_thr, etot_value)
-
- # Set SCF conversion threshold
- scf_value = num_atoms * parameters["meta_parameters"]["conv_thr_per_atom"]
- self.set_value_and_step(self.scf_conv_thr, scf_value)
-
- # Set force conversion threshold
- forc_value = parameters["pw"]["parameters"]["CONTROL"]["forc_conv_thr"]
- self.set_value_and_step(self.forc_conv_thr, forc_value)
-
- # The pseudo_family read from the protocol (aiida-quantumespresso plugin settings)
- # we override it with the value from the pseudo_family_selector widget
- parameters["pseudo_family"] = self.pseudo_family_selector.value
-
- def _callback_value_set(self, _=None):
- """Callback function to set the parameters"""
- settings = {
- "kpoints_distance": self.kpoints_distance.value,
- "total_charge": self.total_charge.value,
- "degauss": self.smearing.degauss_value,
- "smearing": self.smearing.smearing_value,
- }
-
- self.update_settings(**settings)
-
- def update_settings(self, **kwargs):
- """Set the output dict from the given keyword arguments.
- This function will only update the traitlets but not the widget value.
-
- This function can also be used to set values directly for testing purpose.
- """
- self.value = kwargs
-
- def get_panel_value(self):
- # create the the initial_magnetic_moments as None (Default)
- # XXX: start from parameters = {} and then bundle the settings by purposes (e.g. pw, bands, etc.)
- parameters = {
- "initial_magnetic_moments": None,
- "pw": {
- "parameters": {
- "SYSTEM": {},
- "CONTROL": {},
- "ELECTRONS": {},
- }
- },
- "clean_workdir": self.clean_workdir.value,
- "pseudo_family": self.pseudo_family_selector.value,
- "kpoints_distance": self.value.get("kpoints_distance"),
- }
-
- # Set total charge
- parameters["pw"]["parameters"]["SYSTEM"]["tot_charge"] = self.total_charge.value
-
- if self.hubbard_widget.activate_hubbard.value:
- parameters["hubbard_parameters"] = self.hubbard_widget.hubbard_dict
- if self.hubbard_widget.eigenvalues_label.value:
- parameters["pw"]["parameters"]["SYSTEM"].update(
- self.hubbard_widget.eigenvalues_dict
- )
-
- # add clean_workdir to the parameters
- parameters["clean_workdir"] = self.clean_workdir.value
-
- # add the pseudo_family to the parameters
- parameters["pseudo_family"] = self.pseudo_family_selector.value
- if self.pseudo_setter.pseudos:
- parameters["pw"]["pseudos"] = self.pseudo_setter.pseudos
- parameters["pw"]["parameters"]["SYSTEM"]["ecutwfc"] = (
- self.pseudo_setter.ecutwfc
- )
- parameters["pw"]["parameters"]["SYSTEM"]["ecutrho"] = (
- self.pseudo_setter.ecutrho
- )
-
- if self.van_der_waals.value in ["none", "ts-vdw"]:
- parameters["pw"]["parameters"]["SYSTEM"]["vdw_corr"] = (
- self.van_der_waals.value
- )
- else:
- parameters["pw"]["parameters"]["SYSTEM"]["vdw_corr"] = "dft-d3"
- parameters["pw"]["parameters"]["SYSTEM"]["dftd3_version"] = (
- self.dftd3_version[self.van_der_waals.value]
- )
-
- # there are two choose, use link or parent
- if self.spin_type == "collinear":
- parameters["initial_magnetic_moments"] = (
- self.magnetization.get_magnetization()
- )
- parameters["kpoints_distance"] = self.value.get("kpoints_distance")
- if self.electronic_type == "metal":
- # smearing type setting
- parameters["pw"]["parameters"]["SYSTEM"]["smearing"] = (
- self.smearing.smearing_value
- )
- # smearing degauss setting
- parameters["pw"]["parameters"]["SYSTEM"]["degauss"] = (
- self.smearing.degauss_value
- )
-
- # Set tot_magnetization for collinear simulations.
- if self.spin_type == "collinear":
- # Conditions for metallic systems. Select the magnetization type and set the value if override is True
- if self.electronic_type == "metal" and self.override.value is True:
- self.set_metallic_magnetization(parameters)
- # Conditions for insulator systems. Default value is 0.0
- elif self.electronic_type == "insulator":
- self.set_insulator_magnetization(parameters)
-
- # convergence threshold setting
- parameters["pw"]["parameters"]["CONTROL"]["forc_conv_thr"] = (
- self.forc_conv_thr.value
- )
- parameters["pw"]["parameters"]["ELECTRONS"]["conv_thr"] = (
- self.scf_conv_thr.value
- )
- parameters["pw"]["parameters"]["CONTROL"]["etot_conv_thr"] = (
- self.etot_conv_thr.value
- )
-
- # Max electron SCF steps
- parameters["pw"]["parameters"]["ELECTRONS"]["electron_maxstep"] = (
- self.electron_maxstep.value
- )
-
- # Spin-Orbit calculation
- if self.spin_orbit.value == "soc":
- parameters["pw"]["parameters"]["SYSTEM"]["lspinorb"] = True
- parameters["pw"]["parameters"]["SYSTEM"]["noncolin"] = True
- parameters["pw"]["parameters"]["SYSTEM"]["nspin"] = 4
-
- return parameters
-
- def set_insulator_magnetization(self, parameters):
- """Set the parameters for collinear insulator calculation. Total magnetization."""
- parameters["pw"]["parameters"]["SYSTEM"]["tot_magnetization"] = (
- self.magnetization.tot_magnetization.value
- )
-
- def set_metallic_magnetization(self, parameters):
- """Set the parameters for magnetization calculation in metals"""
- magnetization_type = self.magnetization.magnetization_type.value
- if magnetization_type == "tot_magnetization":
- parameters["pw"]["parameters"]["SYSTEM"]["tot_magnetization"] = (
- self.magnetization.tot_magnetization.value
- )
- else:
- parameters["initial_magnetic_moments"] = (
- self.magnetization.get_magnetization()
- )
-
- def set_panel_value(self, parameters):
- """Set the panel value from the given parameters."""
-
- if "pseudo_family" in parameters:
- pseudo_family_string = parameters["pseudo_family"]
- self.pseudo_family_selector.load_from_pseudo_family(
- PseudoFamily.from_string(pseudo_family_string)
- )
- if "pseudos" in parameters["pw"]:
- self.pseudo_setter.set_pseudos(parameters["pw"]["pseudos"], {})
- self.pseudo_setter.ecutwfc_setter.value = parameters["pw"]["parameters"][
- "SYSTEM"
- ]["ecutwfc"]
- self.pseudo_setter.ecutrho_setter.value = parameters["pw"]["parameters"][
- "SYSTEM"
- ]["ecutrho"]
- #
- self.kpoints_distance.value = parameters.get("kpoints_distance", 0.15)
- if parameters.get("pw") is not None:
- system = parameters["pw"]["parameters"]["SYSTEM"]
- if "degauss" in system:
- self.smearing.degauss.value = system["degauss"]
- if "smearing" in system:
- self.smearing.smearing.value = system["smearing"]
- self.total_charge.value = parameters["pw"]["parameters"]["SYSTEM"].get(
- "tot_charge", 0
- )
- if "lspinorb" in system:
- self.spin_orbit.value = "soc"
- else:
- self.spin_orbit.value = "wo_soc"
- # van der waals correction
- self.van_der_waals.value = self.dftd3_version.get(
- system.get("dftd3_version"),
- parameters["pw"]["parameters"]["SYSTEM"].get("vdw_corr", "none"),
- )
-
- # convergence threshold setting
- self.forc_conv_thr.value = (
- parameters.get("pw", {})
- .get("parameters", {})
- .get("CONTROL", {})
- .get("forc_conv_thr", 0.0)
- )
- self.etot_conv_thr.value = (
- parameters.get("pw", {})
- .get("parameters", {})
- .get("CONTROL", {})
- .get("etot_conv_thr", 0.0)
- )
- self.scf_conv_thr.value = (
- parameters.get("pw", {})
- .get("parameters", {})
- .get("ELECTRONS", {})
- .get("conv_thr", 0.0)
- )
-
- # Max electron SCF steps
- self.electron_maxstep.value = (
- parameters.get("pw", {})
- .get("parameters", {})
- .get("ELECTRONS", {})
- .get("electron_maxstep", 80)
- )
-
- # Logic to set the magnetization
- if parameters.get("initial_magnetic_moments"):
- self.magnetization._set_magnetization_values(
- parameters.get("initial_magnetic_moments")
- )
-
- if "tot_magnetization" in parameters["pw"]["parameters"]["SYSTEM"]:
- self.magnetization.magnetization_type.value = "tot_magnetization"
- self.magnetization._set_tot_magnetization(
- parameters["pw"]["parameters"]["SYSTEM"]["tot_magnetization"]
- )
-
- if parameters.get("hubbard_parameters"):
- self.hubbard_widget.activate_hubbard.value = True
- self.hubbard_widget.set_hubbard_widget(
- parameters["hubbard_parameters"]["hubbard_u"]
- )
- starting_ns_eigenvalue = (
- parameters.get("pw", {})
- .get("parameters", {})
- .get("SYSTEM", {})
- .get("starting_ns_eigenvalue")
- )
-
- if starting_ns_eigenvalue is not None:
- self.hubbard_widget.eigenvalues_label.value = True
- self.hubbard_widget.set_eigenvalues_widget(starting_ns_eigenvalue)
-
- def reset(self):
- """Reset the widget and the traitlets"""
-
- with self.hold_trait_notifications():
- # Reset protocol dependent settings
- self._update_settings_from_protocol(self.protocol)
-
- # reset the pseudo family
- self.pseudo_family_selector.reset()
-
- # reset total charge
- self.total_charge.value = DEFAULT_PARAMETERS["advanced"]["tot_charge"]
-
- # reset the van der waals correction
- self.van_der_waals.value = DEFAULT_PARAMETERS["advanced"]["vdw_corr"]
-
- # reset the override checkbox
- self.override.value = False
- self.smearing.reset()
- # reset the pseudo setter
- if self.input_structure is None:
- self.pseudo_setter.structure = None
- self.pseudo_setter._reset()
- else:
- self.pseudo_setter._reset()
- if self.input_structure.pbc == (False, False, False):
- self.kpoints_distance.value = 100.0
- self.kpoints_distance.disabled = True
-
- # reset the magnetization
- self.magnetization.reset()
- # reset the hubbard widget
- self.hubbard_widget.reset()
- # reset mesh grid
- if self.input_structure is None:
- self.mesh_grid.value = " "
-
- def _display_mesh(self, _=None):
- if self.input_structure is None:
- return
- if self.kpoints_distance.value > 0:
- # To avoid creating an aiida node every time we change the kpoints_distance,
- # we use the function itself instead of the decorated calcfunction.
- mesh = create_kpoints_from_distance.process_class._func(
- self.input_structure,
- orm.Float(self.kpoints_distance.value),
- orm.Bool(False),
- )
- self.mesh_grid.value = "Mesh " + str(mesh.get_kpoints_mesh()[0])
- else:
- self.mesh_grid.value = "Please select a number higher than 0.0"
-
-
-class MagnetizationSettings(ipw.VBox):
- """Widget to set the type of magnetization used in the calculation:
- 1) Tot_magnetization: Total majority spin charge - minority spin charge.
- 2) Starting magnetization: Starting spin polarization on atomic type 'i' in a spin polarized (LSDA or noncollinear/spin-orbit) calculation.
-
- For Starting magnetization you can set each kind names defined in the StructureData (StructureDtaa.get_kind_names())
- Usually these are the names of the elements in the StructureData
- (For example 'C' , 'N' , 'Fe' . However the StructureData can have defined kinds like 'Fe1' and 'Fe2')
- The widget generate a dictionary that can be used to set initial_magnetic_moments in the builder of PwBaseWorkChain
-
- Attributes:
- input_structure(StructureData): trait that containes the input_strucgure (confirmed structure from previous step)
- """
-
- input_structure = tl.Instance(orm.StructureData, allow_none=True)
- electronic_type = tl.Unicode()
- disabled = tl.Bool()
- _DEFAULT_TOT_MAGNETIZATION = 0.0
- _DEFAULT_DESCRIPTION = "Magnetization: Input structure not confirmed"
-
- def __init__(self, **kwargs):
- self.input_structure = orm.StructureData()
- self.input_structure_labels = []
- self.tot_magnetization = ipw.BoundedIntText(
- min=0,
- max=100,
- step=1,
- value=self._DEFAULT_TOT_MAGNETIZATION,
- disabled=True,
- description="Total magnetization:",
- style={"description_width": "initial"},
- )
- self.magnetization_type = ipw.ToggleButtons(
- options=[
- ("Starting Magnetization", "starting_magnetization"),
- ("Tot. Magnetization", "tot_magnetization"),
- ],
- value="starting_magnetization",
- style={"description_width": "initial"},
- )
- self.description = ipw.HTML(self._DEFAULT_DESCRIPTION)
- self.kinds = self.create_kinds_widget()
- self.kinds_widget_out = ipw.Output()
- self.magnetization_out = ipw.Output()
- self.magnetization_type.observe(self._render, "value")
- super().__init__(
- children=[
- self.description,
- self.magnetization_out,
- self.kinds_widget_out,
- ],
- layout=ipw.Layout(justify_content="space-between"),
- **kwargs,
- )
-
- @tl.observe("disabled")
- def _disabled_changed(self, _):
- """Disable the widget"""
- if hasattr(self.kinds, "children") and self.kinds.children:
- for i in range(len(self.kinds.children)):
- self.kinds.children[i].disabled = self.disabled
- self.tot_magnetization.disabled = self.disabled
- self.magnetization_type.disabled = self.disabled
-
- def reset(self):
- self.disabled = True
- self.tot_magnetization.value = self._DEFAULT_TOT_MAGNETIZATION
- #
- if self.input_structure is None:
- self.description.value = self._DEFAULT_DESCRIPTION
- self.kinds = None
- else:
- self.description.value = "Magnetization"
- self.kinds = self.create_kinds_widget()
-
- def create_kinds_widget(self):
- if self.input_structure_labels:
- widgets_list = []
- for kind_label in self.input_structure_labels:
- kind_widget = ipw.BoundedFloatText(
- description=kind_label,
- min=-4,
- max=4,
- step=0.1,
- value=0.0,
- disabled=True,
- )
- widgets_list.append(kind_widget)
- kinds_widget = ipw.VBox(widgets_list)
- else:
- kinds_widget = None
-
- return kinds_widget
-
- @tl.observe("electronic_type")
- def _electronic_type_changed(self, change):
- with self.magnetization_out:
- clear_output()
- if change["new"] == "metal":
- display(self.magnetization_type)
- self._render({"new": self.magnetization_type.value})
- else:
- display(self.tot_magnetization)
- with self.kinds_widget_out:
- clear_output()
-
- def update_kinds_widget(self):
- self.input_structure_labels = self.input_structure.get_kind_names()
- self.kinds = self.create_kinds_widget()
- self.description.value = "Magnetization"
-
- def _render(self, value):
- if value["new"] == "tot_magnetization":
- with self.kinds_widget_out:
- clear_output()
- display(self.tot_magnetization)
- else:
- self.display_kinds()
-
- def display_kinds(self):
- if "PYTEST_CURRENT_TEST" not in os.environ and self.kinds:
- with self.kinds_widget_out:
- clear_output()
- display(self.kinds)
-
- def _update_widget(self, change):
- self.input_structure = change["new"]
- self.update_kinds_widget()
- self.display_kinds()
-
- def get_magnetization(self):
- """Method to generate the dictionary with the initial magnetic moments"""
- magnetization = {}
- for i in range(len(self.kinds.children)):
- magnetization[self.input_structure_labels[i]] = self.kinds.children[i].value
- return magnetization
-
- def _set_magnetization_values(self, magnetic_moments):
- """Set magnetization"""
- # self.override.value = True
- with self.hold_trait_notifications():
- for i in range(len(self.kinds.children)):
- if isinstance(magnetic_moments, dict):
- self.kinds.children[i].value = magnetic_moments.get(
- self.kinds.children[i].description, 0.0
- )
- else:
- self.kinds.children[i].value = magnetic_moments
-
- def _set_tot_magnetization(self, tot_magnetization):
- """Set the total magnetization"""
- self.tot_magnetization.value = tot_magnetization
-
-
-class SmearingSettings(ipw.VBox):
- # accept protocol as input and set the values
- protocol = tl.Unicode(allow_none=True)
-
- # The output of the widget is a dictionary with the values of smearing and degauss
- degauss_value = tl.Float()
- smearing_value = tl.Unicode()
-
- smearing_description = ipw.HTML(
- """
- The smearing type and width is set by the chosen protocol.
- Tick the box to override the default, not advised unless you've mastered smearing effects (click here for a discussion).
-
"""
- )
- disabled = tl.Bool()
-
- def __init__(self, default_protocol=None, **kwargs):
- self._default_protocol = (
- default_protocol or DEFAULT_PARAMETERS["workchain"]["protocol"]
- )
-
- self.smearing = ipw.Dropdown(
- options=["cold", "gaussian", "fermi-dirac", "methfessel-paxton"],
- description="Smearing type:",
- disabled=False,
- style={"description_width": "initial"},
- )
- self.degauss = ipw.FloatText(
- step=0.005,
- description="Smearing width (Ry):",
- disabled=False,
- style={"description_width": "initial"},
- )
- ipw.dlink(
- (self, "disabled"),
- (self.degauss, "disabled"),
- )
- ipw.dlink(
- (self, "disabled"),
- (self.smearing, "disabled"),
- )
- self.degauss.observe(self._callback_value_set, "value")
- self.smearing.observe(self._callback_value_set, "value")
-
- super().__init__(
- children=[
- self.smearing_description,
- ipw.HBox([self.smearing, self.degauss]),
- ],
- layout=ipw.Layout(justify_content="space-between"),
- **kwargs,
- )
-
- # Default settings to trigger the callback
- self.protocol = self._default_protocol
-
- @tl.default("disabled")
- def _default_disabled(self):
- return False
-
- @tl.observe("protocol")
- def _protocol_changed(self, _):
- """Input protocol changed, update the widget values."""
- self._update_settings_from_protocol(self.protocol)
-
- def _update_settings_from_protocol(self, protocol):
- """Update the widget values from the given protocol, and trigger the callback."""
- parameters = PwBaseWorkChain.get_protocol_inputs(protocol)["pw"]["parameters"][
- "SYSTEM"
- ]
-
- with self.hold_trait_notifications():
- # This changes will trigger callbacks
- self.degauss.value = parameters["degauss"]
- self.smearing.value = parameters["smearing"]
-
- def _callback_value_set(self, _=None):
- """callback function to set the smearing and degauss values"""
- settings = {
- "degauss": self.degauss.value,
- "smearing": self.smearing.value,
- }
- self.update_settings(**settings)
-
- def update_settings(self, **kwargs):
- """Set the output dict from the given keyword arguments.
- This function will only update the traitlets but not the widget value.
- """
- self.degauss_value = kwargs.get("degauss")
- self.smearing_value = kwargs.get("smearing")
-
- def reset(self):
- """Reset the widget and the traitlets"""
- self.protocol = self._default_protocol
-
- with self.hold_trait_notifications():
- self._update_settings_from_protocol(self.protocol)
- self.disabled = True
diff --git a/src/aiidalab_qe/app/configuration/advanced/__init__.py b/src/aiidalab_qe/app/configuration/advanced/__init__.py
new file mode 100644
index 000000000..93bc2dbf5
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/advanced/__init__.py
@@ -0,0 +1,7 @@
+from .advanced import AdvancedSettings
+from .model import AdvancedModel
+
+__all__ = [
+ "AdvancedModel",
+ "AdvancedSettings",
+]
diff --git a/src/aiidalab_qe/app/configuration/advanced/advanced.py b/src/aiidalab_qe/app/configuration/advanced/advanced.py
new file mode 100644
index 000000000..15c698d15
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/advanced/advanced.py
@@ -0,0 +1,344 @@
+"""Widgets for the submission of bands work chains.
+
+Authors: AiiDAlab team
+"""
+
+import ipywidgets as ipw
+
+from aiidalab_qe.common.panel import SettingsPanel
+
+from .hubbard import HubbardModel, HubbardSettings
+from .magnetization import MagnetizationModel, MagnetizationSettings
+from .model import AdvancedModel
+from .pseudos import PseudoSettings, PseudosModel
+from .smearing import SmearingModel, SmearingSettings
+
+
+class AdvancedSettings(SettingsPanel[AdvancedModel]):
+ title = "Advanced Settings"
+ identifier = "advanced"
+
+ def __init__(self, model: AdvancedModel, **kwargs):
+ super().__init__(
+ model=model,
+ layout={"justify_content": "space-between", **kwargs.get("layout", {})},
+ **kwargs,
+ )
+
+ self._model.observe(
+ self._on_input_structure_change,
+ "input_structure",
+ )
+ self._model.observe(
+ self._on_protocol_change,
+ "protocol",
+ )
+ self._model.observe(
+ self._on_override_change,
+ "override",
+ )
+ self._model.observe(
+ self._on_kpoints_distance_change,
+ "kpoints_distance",
+ )
+
+ smearing_model = SmearingModel()
+ self.smearing = SmearingSettings(model=smearing_model)
+ model.add_model("smearing", smearing_model)
+
+ magnetization_model = MagnetizationModel()
+ self.magnetization = MagnetizationSettings(model=magnetization_model)
+ model.add_model("magnetization", magnetization_model)
+
+ hubbard_model = HubbardModel()
+ self.hubbard = HubbardSettings(model=hubbard_model)
+ model.add_model("hubbard", hubbard_model)
+
+ pseudos_model = PseudosModel()
+ self.pseudos = PseudoSettings(model=pseudos_model)
+ model.add_model("pseudos", pseudos_model)
+
+ def render(self):
+ if self.rendered:
+ return
+
+ # clean-up workchain settings
+ self.clean_workdir = ipw.Checkbox(
+ description="",
+ indent=False,
+ layout=ipw.Layout(max_width="20px"),
+ )
+ ipw.link(
+ (self._model, "clean_workdir"),
+ (self.clean_workdir, "value"),
+ )
+ # Override setting widget
+ self.override = ipw.Checkbox(
+ description="",
+ indent=False,
+ layout=ipw.Layout(max_width="10%"),
+ )
+ ipw.link(
+ (self._model, "override"),
+ (self.override, "value"),
+ )
+ ipw.dlink(
+ (self._model, "input_structure"),
+ (self.override, "disabled"),
+ lambda structure: structure is None,
+ )
+
+ # Smearing setting widget
+ self.smearing.render()
+
+ # Kpoints setting widget
+ self.kpoints_distance = ipw.BoundedFloatText(
+ min=0.0,
+ step=0.05,
+ description="K-points distance (1/Å):",
+ style={"description_width": "initial"},
+ )
+ ipw.link(
+ (self._model, "kpoints_distance"),
+ (self.kpoints_distance, "value"),
+ )
+ ipw.dlink(
+ (self.override, "value"),
+ (self.kpoints_distance, "disabled"),
+ lambda override: not (override and self._model.has_pbc),
+ )
+ ipw.dlink(
+ (self._model, "input_structure"),
+ (self.kpoints_distance, "disabled"),
+ lambda _: not (self._model.override and self._model.has_pbc),
+ )
+ self.mesh_grid = ipw.HTML()
+ ipw.dlink(
+ (self._model, "mesh_grid"),
+ (self.mesh_grid, "value"),
+ )
+
+ # Hubbard setting widget
+ self.hubbard.render()
+
+ # Total change setting widget
+ self.total_charge = ipw.BoundedFloatText(
+ min=-3,
+ max=3,
+ step=0.01,
+ description="Total charge:",
+ style={"description_width": "initial"},
+ )
+ ipw.link(
+ (self._model, "total_charge"),
+ (self.total_charge, "value"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.total_charge, "disabled"),
+ lambda override: not override,
+ )
+
+ # Van der Waals setting widget
+ self.van_der_waals = ipw.Dropdown(
+ description="Van der Waals correction:",
+ style={"description_width": "initial"},
+ )
+ ipw.dlink(
+ (self._model, "van_der_waals_options"),
+ (self.van_der_waals, "options"),
+ )
+ ipw.link(
+ (self._model, "van_der_waals"),
+ (self.van_der_waals, "value"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.van_der_waals, "disabled"),
+ lambda override: not override,
+ )
+
+ # Magnetization settings
+ self.magnetization.render()
+
+ # Convergence Threshold settings
+ self.scf_conv_thr = ipw.BoundedFloatText(
+ min=1e-15,
+ max=1.0,
+ description="SCF conv.:",
+ style={"description_width": "initial"},
+ )
+ ipw.link(
+ (self._model, "scf_conv_thr"),
+ (self.scf_conv_thr, "value"),
+ )
+ ipw.dlink(
+ (self._model, "scf_conv_thr_step"),
+ (self.scf_conv_thr, "step"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.scf_conv_thr, "disabled"),
+ lambda override: not override,
+ )
+ self.forc_conv_thr = ipw.BoundedFloatText(
+ min=1e-15,
+ max=1.0,
+ description="Force conv.:",
+ style={"description_width": "initial"},
+ )
+ ipw.link(
+ (self._model, "forc_conv_thr"),
+ (self.forc_conv_thr, "value"),
+ )
+ ipw.dlink(
+ (self._model, "forc_conv_thr_step"),
+ (self.forc_conv_thr, "step"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.forc_conv_thr, "disabled"),
+ lambda override: not override,
+ )
+ self.etot_conv_thr = ipw.BoundedFloatText(
+ min=1e-15,
+ max=1.0,
+ description="Energy conv.:",
+ style={"description_width": "initial"},
+ )
+ ipw.link(
+ (self._model, "etot_conv_thr"),
+ (self.etot_conv_thr, "value"),
+ )
+ ipw.dlink(
+ (self._model, "etot_conv_thr_step"),
+ (self.etot_conv_thr, "step"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.etot_conv_thr, "disabled"),
+ lambda override: not override,
+ )
+ self.electron_maxstep = ipw.BoundedIntText(
+ min=20,
+ max=1000,
+ step=1,
+ description="Max. electron steps:",
+ style={"description_width": "initial"},
+ )
+ ipw.link(
+ (self._model, "electron_maxstep"),
+ (self.electron_maxstep, "value"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.electron_maxstep, "disabled"),
+ lambda override: not override,
+ )
+
+ # Spin-Orbit calculation
+ self.spin_orbit = ipw.ToggleButtons(
+ description="Spin-Orbit:",
+ style={"description_width": "initial"},
+ )
+ ipw.dlink(
+ (self._model, "spin_orbit_options"),
+ (self.spin_orbit, "options"),
+ )
+ ipw.link(
+ (self._model, "spin_orbit"),
+ (self.spin_orbit, "value"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.spin_orbit, "disabled"),
+ lambda override: not override,
+ )
+
+ self.pseudos.render()
+
+ self.children = [
+ ipw.HTML("""
+
+
Advanced Settings
+
+ """),
+ ipw.HBox(
+ children=[
+ self.clean_workdir,
+ ipw.HTML("""
+
+ Tick to clean-up the work directory after the calculation is finished.
+
+ """),
+ ],
+ layout=ipw.Layout(height="50px", justify_content="flex-start"),
+ ),
+ ipw.HBox(
+ children=[
+ ipw.HTML("""
+ Select the advanced settings for the pw.x code.
+ """),
+ ipw.HBox(
+ children=[
+ ipw.HTML(
+ value="Override",
+ layout=ipw.Layout(margin="0 5px 0 0"),
+ ),
+ self.override,
+ ],
+ layout=ipw.Layout(max_width="20%"),
+ ),
+ ],
+ layout=ipw.Layout(height="50px", justify_content="space-between"),
+ ),
+ self.total_charge,
+ self.van_der_waals,
+ self.magnetization,
+ ipw.HTML("Convergence Thresholds:"),
+ ipw.HBox(
+ children=[
+ self.forc_conv_thr,
+ self.etot_conv_thr,
+ self.scf_conv_thr,
+ ],
+ layout=ipw.Layout(height="50px", justify_content="flex-start"),
+ ),
+ self.electron_maxstep,
+ self.smearing,
+ ipw.HTML("""
+
+ The k-points mesh density of the SCF calculation is set by the
+ protocol. The value below represents the maximum distance
+ between the k-points in each direction of reciprocal space. Tick
+ the box to override the default, smaller is more accurate and
+ costly.
+
+ """),
+ ipw.HBox(
+ children=[
+ self.kpoints_distance,
+ self.mesh_grid,
+ ]
+ ),
+ self.hubbard,
+ self.spin_orbit,
+ self.pseudos,
+ ]
+
+ self.rendered = True
+
+ self.refresh()
+
+ def _on_input_structure_change(self, _):
+ self.refresh(specific="structure")
+
+ def _on_protocol_change(self, _):
+ self.refresh(specific="protocol")
+
+ def _on_kpoints_distance_change(self, _):
+ self.refresh(specific="mesh")
+
+ def _on_override_change(self, change):
+ if not change["new"]:
+ self._model.reset()
diff --git a/src/aiidalab_qe/app/configuration/advanced/hubbard/__init__.py b/src/aiidalab_qe/app/configuration/advanced/hubbard/__init__.py
new file mode 100644
index 000000000..269e51e35
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/advanced/hubbard/__init__.py
@@ -0,0 +1,7 @@
+from .hubbard import HubbardSettings
+from .model import HubbardModel
+
+__all__ = [
+ "HubbardSettings",
+ "HubbardModel",
+]
diff --git a/src/aiidalab_qe/app/configuration/advanced/hubbard/hubbard.py b/src/aiidalab_qe/app/configuration/advanced/hubbard/hubbard.py
new file mode 100644
index 000000000..9628bfaca
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/advanced/hubbard/hubbard.py
@@ -0,0 +1,271 @@
+import ipywidgets as ipw
+
+from ..subsettings import AdvancedSubSettings
+from .model import HubbardModel
+
+
+class HubbardSettings(AdvancedSubSettings[HubbardModel]):
+ identifier = "hubbard"
+
+ def __init__(self, model: HubbardModel, **kwargs):
+ super().__init__(model, **kwargs)
+
+ self._model.observe(
+ self._on_input_structure_change,
+ "input_structure",
+ )
+ self._model.observe(
+ self._on_hubbard_activation,
+ "is_active",
+ )
+ self._model.observe(
+ self._on_eigenvalues_definition,
+ "has_eigenvalues",
+ )
+
+ def render(self):
+ if self.rendered:
+ return
+
+ self.activate_hubbard_checkbox = ipw.Checkbox(
+ description="",
+ indent=False,
+ layout=ipw.Layout(max_width="10%"),
+ )
+ ipw.link(
+ (self._model, "is_active"),
+ (self.activate_hubbard_checkbox, "value"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.activate_hubbard_checkbox, "disabled"),
+ lambda override: not override,
+ )
+
+ self.eigenvalues_help = ipw.HTML(
+ value="For transition metals and lanthanoids, the starting eigenvalues can be defined (Magnetic calculation).",
+ layout=ipw.Layout(width="auto"),
+ )
+ self.define_eigenvalues_checkbox = ipw.Checkbox(
+ description="Define eigenvalues",
+ indent=False,
+ layout=ipw.Layout(max_width="30%"),
+ )
+ ipw.link(
+ (self._model, "has_eigenvalues"),
+ (self.define_eigenvalues_checkbox, "value"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.define_eigenvalues_checkbox, "disabled"),
+ lambda override: not override,
+ )
+
+ self.eigenvalues_container = ipw.VBox(
+ children=[
+ self.eigenvalues_help,
+ self.define_eigenvalues_checkbox,
+ ]
+ )
+
+ self.hubbard_widget = ipw.VBox()
+ self.eigenvalues_widget = ipw.VBox()
+
+ self.container = ipw.VBox()
+
+ self.children = [
+ ipw.HBox(
+ children=[
+ ipw.HTML("Hubbard (DFT+U)"),
+ self.activate_hubbard_checkbox,
+ ]
+ ),
+ self.container,
+ ]
+
+ self.rendered = True
+
+ self.refresh(specific="widgets")
+
+ def _on_input_structure_change(self, _):
+ self.refresh(specific="structure")
+
+ def _on_hubbard_activation(self, _):
+ self._toggle_hubbard_widget()
+
+ def _on_eigenvalues_definition(self, _):
+ self._toggle_eigenvalues_widget()
+
+ def _update(self, specific=""):
+ if self.updated:
+ return
+ self._show_loading()
+ if not self._model.loaded_from_process or specific and specific != "widgets":
+ self._model.update(specific)
+ self._build_hubbard_widget()
+ self._toggle_hubbard_widget()
+ self._toggle_eigenvalues_widget()
+ self.updated = True
+
+ def _show_loading(self):
+ if self.rendered:
+ self.hubbard_widget.children = [self.loading_message]
+
+ def _build_hubbard_widget(self):
+ if not self.rendered:
+ return
+
+ children = []
+
+ if self._model.input_structure:
+ children.append(ipw.HTML("Define U value [eV] "))
+
+ for label in self._model.orbital_labels:
+ float_widget = ipw.BoundedFloatText(
+ description=label,
+ min=0,
+ max=20,
+ step=0.1,
+ layout={"width": "160px"},
+ )
+ link = ipw.link(
+ (self._model, "parameters"),
+ (float_widget, "value"),
+ [
+ lambda parameters, label=label: parameters.get(label, 0.0),
+ lambda value, label=label: {
+ **self._model.parameters,
+ label: value,
+ },
+ ],
+ )
+ self.links.append(link)
+ children.append(float_widget)
+
+ if self._model.needs_eigenvalues_widget:
+ children.append(self.eigenvalues_container)
+ self._build_eigenvalues_widget()
+ else:
+ self.eigenvalues_widget.children = []
+
+ self.hubbard_widget.children = children
+
+ def _build_eigenvalues_widget(self):
+ def update(index, spin, state, symbol, value):
+ eigenvalues = [*self._model.eigenvalues]
+ eigenvalues[index][spin][state] = [state + 1, spin, symbol, value]
+ return eigenvalues
+
+ children = []
+
+ for (
+ kind_index,
+ (kind_name, num_states),
+ ) in enumerate(self._model.applicable_kind_names):
+ label_layout = ipw.Layout(justify_content="flex-start", width="50px")
+ spin_up_row = ipw.HBox([ipw.Label("Up:", layout=label_layout)])
+ spin_down_row = ipw.HBox([ipw.Label("Down:", layout=label_layout)])
+
+ for state_index in range(num_states):
+ eigenvalues_up = ipw.Dropdown(
+ description=f"{state_index+1}",
+ layout=ipw.Layout(width="65px"),
+ style={"description_width": "initial"},
+ )
+ options_link = ipw.dlink(
+ (self._model, "eigenvalue_options"),
+ (eigenvalues_up, "options"),
+ )
+ value_link = ipw.link(
+ (self._model, "eigenvalues"),
+ (eigenvalues_up, "value"),
+ [
+ lambda eigenvalues,
+ kind_index=kind_index,
+ state_index=state_index: str(
+ eigenvalues[kind_index][0][state_index][-1]
+ ),
+ lambda value,
+ kind_index=kind_index,
+ state_index=state_index,
+ kind_name=kind_name: update(
+ kind_index,
+ 0,
+ state_index,
+ kind_name,
+ int(value),
+ ),
+ ],
+ )
+ self.links.extend([options_link, value_link])
+ spin_up_row.children += (eigenvalues_up,)
+
+ eigenvalues_down = ipw.Dropdown(
+ description=f"{state_index+1}",
+ layout=ipw.Layout(width="65px"),
+ style={"description_width": "initial"},
+ )
+ options_link = ipw.dlink(
+ (self._model, "eigenvalue_options"),
+ (eigenvalues_down, "options"),
+ )
+ value_link = ipw.link(
+ (self._model, "eigenvalues"),
+ (eigenvalues_down, "value"),
+ [
+ lambda eigenvalues,
+ kind_index=kind_index,
+ state_index=state_index: str(
+ eigenvalues[kind_index][1][state_index][-1]
+ ),
+ lambda value,
+ kind_index=kind_index,
+ state_index=state_index,
+ kind_name=kind_name: update(
+ kind_index,
+ 1,
+ state_index,
+ kind_name,
+ int(value),
+ ),
+ ],
+ )
+ self.links.extend([options_link, value_link])
+ spin_down_row.children += (eigenvalues_down,)
+
+ children.append(
+ ipw.HBox(
+ [
+ ipw.Label(kind_name, layout=label_layout),
+ ipw.VBox(
+ children=[
+ spin_up_row,
+ spin_down_row,
+ ]
+ ),
+ ]
+ )
+ )
+
+ self.eigenvalues_widget.children = children
+
+ def _toggle_hubbard_widget(self):
+ if not self.rendered:
+ return
+ self.container.children = [self.hubbard_widget] if self._model.is_active else []
+
+ def _toggle_eigenvalues_widget(self):
+ if not self.rendered:
+ return
+ self.eigenvalues_container.children = (
+ [
+ self.eigenvalues_help,
+ self.define_eigenvalues_checkbox,
+ self.eigenvalues_widget,
+ ]
+ if self._model.has_eigenvalues
+ else [
+ self.eigenvalues_help,
+ self.define_eigenvalues_checkbox,
+ ]
+ )
diff --git a/src/aiidalab_qe/app/configuration/advanced/hubbard/model.py b/src/aiidalab_qe/app/configuration/advanced/hubbard/model.py
new file mode 100644
index 000000000..a0da5b21f
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/advanced/hubbard/model.py
@@ -0,0 +1,180 @@
+from __future__ import annotations
+
+from copy import deepcopy
+
+import numpy as np
+import traitlets as tl
+from pymatgen.core.periodic_table import Element
+
+from aiida_quantumespresso.data.hubbard_structure import HubbardStructureData
+from aiidalab_qe.common.mixins import HasInputStructure
+
+from ..subsettings import AdvancedSubModel
+
+
+class HubbardModel(AdvancedSubModel, HasInputStructure):
+ dependencies = [
+ "input_structure",
+ ]
+
+ override = tl.Bool()
+
+ is_active = tl.Bool(False)
+ has_eigenvalues = tl.Bool(False)
+ parameters = tl.Dict(
+ key_trait=tl.Unicode(), # kind name
+ value_trait=tl.Float(), # U value
+ default_value={},
+ )
+ eigenvalue_options = tl.List(
+ trait=tl.Unicode(),
+ default_value=["-1", "0", "1"],
+ )
+ eigenvalues = tl.List(
+ trait=tl.List(), # [[[[state, spin, kind name, eigenvalue] # state] # spin] # kind name]
+ default_value=[],
+ )
+
+ applicable_kind_names = []
+ orbital_labels = []
+
+ def update(self, specific=""): # noqa: ARG002
+ if not self.has_structure:
+ self.applicable_kind_names = []
+ self.orbital_labels = []
+ self._defaults |= {
+ "parameters": {},
+ "eigenvalues": [],
+ }
+ else:
+ self.orbital_labels = self._define_orbital_labels()
+ if isinstance(self.input_structure, HubbardStructureData):
+ self._defaults["parameters"] = (
+ self.get_parameters_from_hubbard_structure()
+ )
+ self.is_active = True
+ else:
+ self._defaults["parameters"] = self._define_default_parameters()
+ self.applicable_kind_names = self._define_applicable_kind_names()
+ self._defaults["eigenvalues"] = self._define_default_eigenvalues()
+ with self.hold_trait_notifications():
+ self.parameters = self._get_default_parameters()
+ self.eigenvalues = self._get_default_eigenvalues()
+ self.needs_eigenvalues_widget = len(self.applicable_kind_names) > 0
+
+ def get_active_eigenvalues(self):
+ active_eigenvalues = [
+ orbital_eigenvalue
+ for element_eigenvalues in self.eigenvalues
+ for spin_row in element_eigenvalues
+ for orbital_eigenvalue in spin_row
+ if orbital_eigenvalue[-1] != -1
+ ]
+ eigenvalues_array = np.array(active_eigenvalues, dtype=object)
+ new_shape = (np.prod(eigenvalues_array.shape[:-1]), 4)
+ return eigenvalues_array.reshape(new_shape).tolist()
+
+ def set_active_eigenvalues(self, eigenvalues: list):
+ eigenvalues_array = np.array(eigenvalues, dtype=object)
+ num_states = len(set(eigenvalues_array[:, 0]))
+ num_spins = len(set(eigenvalues_array[:, 1]))
+ num_kinds = len(set(eigenvalues_array[:, 2]))
+ new_shape = (num_kinds, num_spins, num_states, 4)
+ self.eigenvalues = eigenvalues_array.reshape(new_shape).tolist()
+ self.has_eigenvalues = True
+
+ def get_parameters_from_hubbard_structure(self):
+ hubbard_parameters = self.input_structure.hubbard.dict()["parameters"]
+ sites = self.input_structure.sites
+ return {
+ f"{sites[hp['atom_index']].kind_name} - {hp['atom_manifold']}": hp["value"]
+ for hp in hubbard_parameters
+ }
+
+ def reset(self):
+ with self.hold_trait_notifications():
+ self.is_active = False
+ self.has_eigenvalues = False
+ self.parameters = self._get_default_parameters()
+ self.eigenvalues = self._get_default_eigenvalues()
+
+ def _define_orbital_labels(self):
+ hubbard_manifold_list = [
+ self._get_manifold(Element(kind.symbol))
+ for kind in self.input_structure.kinds
+ ]
+ return [
+ f"{kind_name} - {manifold}"
+ for kind_name, manifold in zip(
+ self.input_structure.get_kind_names(),
+ hubbard_manifold_list,
+ )
+ ]
+
+ def _define_default_parameters(self):
+ return {label: 0.0 for label in self.orbital_labels}
+
+ def _define_applicable_kind_names(self):
+ applicable_kind_names = []
+ for kind in self.input_structure.kinds:
+ element = Element(kind.symbol)
+ if (
+ element.is_transition_metal
+ or element.is_lanthanoid
+ or element.is_actinoid
+ ):
+ num_states = 5 if element.is_transition_metal else 7
+ applicable_kind_names.append((kind.name, num_states))
+ return applicable_kind_names
+
+ def _define_default_eigenvalues(self):
+ return [
+ [
+ [
+ [state + 1, spin, kind_name, -1] # default eigenvalue
+ for state in range(num_states)
+ ]
+ for spin in range(2) # spin up and down
+ ]
+ for kind_name, num_states in self.applicable_kind_names # transition metals and lanthanoids
+ ]
+
+ def _get_default_parameters(self):
+ return deepcopy(self._defaults["parameters"])
+
+ def _get_default_eigenvalues(self):
+ return deepcopy(self._defaults["eigenvalues"])
+
+ def _get_manifold(self, element):
+ valence = [
+ orbital
+ for orbital in element.electronic_structure.split(".")
+ if "[" not in orbital
+ ]
+ orbital_shells = [shell[:2] for shell in valence]
+
+ def is_condition_met(shell):
+ return condition and condition in shell
+
+ # Conditions for determining the Hubbard manifold
+ # to be selected from the electronic structure
+ conditions = {
+ element.is_transition_metal: "d",
+ element.is_lanthanoid or element.is_actinoid: "f",
+ element.is_post_transition_metal
+ or element.is_metalloid
+ or element.is_halogen
+ or element.is_chalcogen
+ or element.symbol in ["C", "N", "P"]: "p",
+ element.is_alkaline or element.is_alkali or element.is_noble_gas: "s",
+ }
+
+ condition = next(
+ (shell for condition, shell in conditions.items() if condition), None
+ )
+
+ hubbard_manifold = next(
+ (shell for shell in orbital_shells if is_condition_met(shell)), None
+ )
+
+ return hubbard_manifold
diff --git a/src/aiidalab_qe/app/configuration/advanced/magnetization/__init__.py b/src/aiidalab_qe/app/configuration/advanced/magnetization/__init__.py
new file mode 100644
index 000000000..a9ac4eb41
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/advanced/magnetization/__init__.py
@@ -0,0 +1,7 @@
+from .magnetization import MagnetizationSettings
+from .model import MagnetizationModel
+
+__all__ = [
+ "MagnetizationModel",
+ "MagnetizationSettings",
+]
diff --git a/src/aiidalab_qe/app/configuration/advanced/magnetization/magnetization.py b/src/aiidalab_qe/app/configuration/advanced/magnetization/magnetization.py
new file mode 100644
index 000000000..620736659
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/advanced/magnetization/magnetization.py
@@ -0,0 +1,191 @@
+import ipywidgets as ipw
+
+from ..subsettings import AdvancedSubSettings
+from .model import MagnetizationModel
+
+
+class MagnetizationSettings(AdvancedSubSettings[MagnetizationModel]):
+ """Widget to set the type of magnetization used in the calculation:
+ 1) Tot_magnetization: Total majority spin charge - minority spin charge.
+ 2) Starting magnetization: Starting spin polarization on atomic type 'i' in a spin polarized (LSDA or noncollinear/spin-orbit) calculation.
+
+ For Starting magnetization you can set each kind names defined in the StructureData (StructureData.get_kind_names())
+ Usually these are the names of the elements in the StructureData
+ (For example 'C' , 'N' , 'Fe' . However the StructureData can have defined kinds like 'Fe1' and 'Fe2')
+ The widget generate a dictionary that can be used to set initial_magnetic_moments in the builder of PwBaseWorkChain
+
+ Attributes:
+ input_structure(StructureData): trait that contains the input_structure (confirmed structure from previous step)
+ """
+
+ identifier = "magnetization"
+
+ def __init__(self, model: MagnetizationModel, **kwargs):
+ super().__init__(model, **kwargs)
+
+ self._model.observe(
+ self._on_input_structure_change,
+ "input_structure",
+ )
+ self._model.observe(
+ self._on_electronic_type_change,
+ "electronic_type",
+ )
+ self._model.observe(
+ self._on_spin_type_change,
+ "spin_type",
+ )
+ self._model.observe(
+ self._on_magnetization_type_change,
+ "type",
+ )
+
+ def render(self):
+ if self.rendered:
+ return
+
+ self.description = ipw.HTML("Magnetization:")
+
+ self.magnetization_type = ipw.ToggleButtons(
+ style={"description_width": "initial"},
+ )
+ ipw.dlink(
+ (self._model, "type_options"),
+ (self.magnetization_type, "options"),
+ )
+ ipw.link(
+ (self._model, "type"),
+ (self.magnetization_type, "value"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.magnetization_type, "disabled"),
+ lambda override: not override,
+ )
+
+ self.tot_magnetization = ipw.BoundedIntText(
+ min=0,
+ max=100,
+ step=1,
+ disabled=True,
+ description="Total magnetization:",
+ style={"description_width": "initial"},
+ )
+ ipw.link(
+ (self._model, "total"),
+ (self.tot_magnetization, "value"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.tot_magnetization, "disabled"),
+ lambda override: not override,
+ )
+
+ self.kind_moment_widgets = ipw.VBox()
+
+ self.container = ipw.VBox(
+ children=[
+ self.tot_magnetization,
+ ]
+ )
+
+ self.children = []
+
+ self.rendered = True
+
+ self.refresh(specific="widgets")
+
+ def _on_input_structure_change(self, _):
+ self.refresh(specific="structure")
+
+ def _on_electronic_type_change(self, _):
+ self._switch_widgets()
+
+ def _on_spin_type_change(self, _):
+ self.refresh(specific="spin")
+
+ def _on_magnetization_type_change(self, _):
+ self._toggle_widgets()
+
+ def _update(self, specific=""):
+ if self.updated:
+ return
+ self._show_loading()
+ if not self._model.loaded_from_process or specific and specific != "widgets":
+ self._model.update(specific)
+ self._build_kinds_widget()
+ self._switch_widgets()
+ self._toggle_widgets()
+ self.updated = True
+
+ def _show_loading(self):
+ if self.rendered:
+ self.kind_moment_widgets.children = [self.loading_message]
+
+ def _build_kinds_widget(self):
+ if not self.rendered:
+ return
+
+ children = []
+
+ kind_names = (
+ self._model.input_structure.get_kind_names()
+ if self._model.input_structure
+ else []
+ )
+
+ for kind_name in kind_names:
+ kind_moment_widget = ipw.BoundedFloatText(
+ description=kind_name,
+ min=-4,
+ max=4,
+ step=0.1,
+ disabled=True,
+ )
+ link = ipw.link(
+ (self._model, "moments"),
+ (kind_moment_widget, "value"),
+ [
+ lambda moments, kind_name=kind_name: moments.get(kind_name, 0.0),
+ lambda value, kind_name=kind_name: {
+ **self._model.moments,
+ kind_name: value,
+ },
+ ],
+ )
+ self.links.append(link)
+ ipw.dlink(
+ (self._model, "override"),
+ (kind_moment_widget, "disabled"),
+ lambda override: not override,
+ )
+ children.append(kind_moment_widget)
+
+ self.kind_moment_widgets.children = children
+
+ def _switch_widgets(self):
+ if not self.rendered:
+ return
+ if self._model.spin_type == "none":
+ children = []
+ else:
+ children = [self.description]
+ if self._model.electronic_type == "metal":
+ children.extend(
+ [
+ self.magnetization_type,
+ self.container,
+ ]
+ )
+ else:
+ children.append(self.tot_magnetization)
+ self.children = children
+
+ def _toggle_widgets(self):
+ if self._model.spin_type == "none" or not self.rendered:
+ return
+ self.container.children = [
+ self.tot_magnetization
+ if self._model.type == "tot_magnetization"
+ else self.kind_moment_widgets
+ ]
diff --git a/src/aiidalab_qe/app/configuration/advanced/magnetization/model.py b/src/aiidalab_qe/app/configuration/advanced/magnetization/model.py
new file mode 100644
index 000000000..27e8ef060
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/advanced/magnetization/model.py
@@ -0,0 +1,53 @@
+from copy import deepcopy
+
+import traitlets as tl
+
+from aiidalab_qe.common.mixins import HasInputStructure
+
+from ..subsettings import AdvancedSubModel
+
+
+class MagnetizationModel(AdvancedSubModel, HasInputStructure):
+ dependencies = [
+ "input_structure",
+ "electronic_type",
+ "spin_type",
+ ]
+
+ electronic_type = tl.Unicode()
+ spin_type = tl.Unicode()
+ override = tl.Bool()
+
+ type_options = tl.List(
+ trait=tl.List(tl.Unicode()),
+ default_value=[
+ ["Starting Magnetization", "starting_magnetization"],
+ ["Tot. Magnetization", "tot_magnetization"],
+ ],
+ )
+ type = tl.Unicode("starting_magnetization")
+ total = tl.Float(0.0)
+ moments = tl.Dict(
+ key_trait=tl.Unicode(), # kind name
+ value_trait=tl.Float(), # magnetic moment
+ default_value={},
+ )
+
+ def update(self, specific=""): # noqa: ARG002
+ if self.spin_type == "none" or not self.has_structure:
+ self._defaults["moments"] = {}
+ else:
+ self._defaults["moments"] = {
+ kind_name: 0.0 for kind_name in self.input_structure.get_kind_names()
+ }
+ with self.hold_trait_notifications():
+ self.moments = self._get_default_moments()
+
+ def reset(self):
+ with self.hold_trait_notifications():
+ self.type = self.traits()["type"].default_value
+ self.total = self.traits()["total"].default_value
+ self.moments = self._get_default_moments()
+
+ def _get_default_moments(self):
+ return deepcopy(self._defaults.get("moments", {}))
diff --git a/src/aiidalab_qe/app/configuration/advanced/model.py b/src/aiidalab_qe/app/configuration/advanced/model.py
new file mode 100644
index 000000000..c40b780be
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/advanced/model.py
@@ -0,0 +1,334 @@
+from __future__ import annotations
+
+import typing as t
+
+import ipywidgets as ipw
+import numpy as np
+import traitlets as tl
+
+from aiida import orm
+from aiida_quantumespresso.calculations.functions.create_kpoints_from_distance import (
+ create_kpoints_from_distance,
+)
+from aiida_quantumespresso.workflows.pw.base import PwBaseWorkChain
+from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS
+from aiidalab_qe.common.mixins import HasInputStructure, HasModels
+from aiidalab_qe.common.panel import SettingsModel
+from aiidalab_qe.setup.pseudos import PseudoFamily
+
+from .subsettings import AdvancedSubModel
+
+if t.TYPE_CHECKING:
+ from .hubbard.hubbard import HubbardModel
+ from .magnetization import MagnetizationModel
+ from .pseudos.pseudos import PseudosModel
+ from .smearing import SmearingModel
+
+DEFAULT: dict = DEFAULT_PARAMETERS # type: ignore
+
+
+class AdvancedModel(
+ SettingsModel,
+ HasModels[AdvancedSubModel],
+ HasInputStructure,
+):
+ dependencies = [
+ "input_structure",
+ "workchain.protocol",
+ "workchain.spin_type",
+ "workchain.electronic_type",
+ ]
+
+ protocol = tl.Unicode()
+ spin_type = tl.Unicode()
+ electronic_type = tl.Unicode()
+
+ clean_workdir = tl.Bool(False)
+ override = tl.Bool(False)
+ total_charge = tl.Float(DEFAULT["advanced"]["tot_charge"])
+ van_der_waals_options = tl.List(
+ trait=tl.List(tl.Unicode()),
+ default_value=[
+ ["None", "none"],
+ ["Grimme-D3", "dft-d3"],
+ ["Grimme-D3BJ", "dft-d3bj"],
+ ["Grimme-D3M", "dft-d3m"],
+ ["Grimme-D3MBJ", "dft-d3mbj"],
+ ["Tkatchenko-Scheffler", "ts-vdw"],
+ ],
+ )
+ van_der_waals = tl.Unicode(DEFAULT["advanced"]["vdw_corr"])
+ spin_orbit_options = tl.List(
+ trait=tl.List(tl.Unicode()),
+ default_value=[
+ ["Off", "wo_soc"],
+ ["On", "soc"],
+ ],
+ )
+ spin_orbit = tl.Unicode("wo_soc")
+ forc_conv_thr = tl.Float(0.0)
+ forc_conv_thr_step = tl.Float(1e-4)
+ etot_conv_thr = tl.Float(0.0)
+ etot_conv_thr_step = tl.Float(1e-5)
+ scf_conv_thr = tl.Float(0.0)
+ scf_conv_thr_step = tl.Float(1e-10)
+ electron_maxstep = tl.Int(80)
+ kpoints_distance = tl.Float(0.0)
+ mesh_grid = tl.Unicode("")
+
+ include = True
+
+ dftd3_version = {
+ "dft-d3": 3,
+ "dft-d3bj": 4,
+ "dft-d3m": 5,
+ "dft-d3mbj": 6,
+ }
+
+ def update(self, specific=""):
+ with self.hold_trait_notifications():
+ if not specific or specific != "mesh":
+ parameters = PwBaseWorkChain.get_protocol_inputs(self.protocol)
+ self._update_kpoints_distance(parameters)
+ self._update_thresholds(parameters)
+ self._update_kpoints_mesh()
+
+ def get_model_state(self):
+ parameters = {
+ "initial_magnetic_moments": None,
+ "pw": {
+ "parameters": {
+ "SYSTEM": {
+ "tot_charge": self.total_charge,
+ },
+ "CONTROL": {
+ "forc_conv_thr": self.forc_conv_thr,
+ "etot_conv_thr": self.etot_conv_thr,
+ },
+ "ELECTRONS": {
+ "conv_thr": self.scf_conv_thr,
+ "electron_maxstep": self.electron_maxstep,
+ },
+ }
+ },
+ "clean_workdir": self.clean_workdir,
+ "kpoints_distance": self.kpoints_distance,
+ }
+
+ hubbard: HubbardModel = self.get_model("hubbard") # type: ignore
+ if hubbard.is_active:
+ parameters["hubbard_parameters"] = {"hubbard_u": hubbard.parameters}
+ if hubbard.has_eigenvalues:
+ parameters["pw"]["parameters"]["SYSTEM"] |= {
+ "starting_ns_eigenvalue": hubbard.get_active_eigenvalues()
+ }
+
+ pseudos: PseudosModel = self.get_model("pseudos") # type: ignore
+ parameters["pseudo_family"] = pseudos.family
+ if pseudos.dictionary:
+ parameters["pw"]["pseudos"] = pseudos.dictionary
+ parameters["pw"]["parameters"]["SYSTEM"]["ecutwfc"] = pseudos.ecutwfc
+ parameters["pw"]["parameters"]["SYSTEM"]["ecutrho"] = pseudos.ecutrho
+
+ if self.van_der_waals in ["none", "ts-vdw"]:
+ parameters["pw"]["parameters"]["SYSTEM"]["vdw_corr"] = self.van_der_waals
+ else:
+ parameters["pw"]["parameters"]["SYSTEM"]["vdw_corr"] = "dft-d3"
+ parameters["pw"]["parameters"]["SYSTEM"]["dftd3_version"] = (
+ self.dftd3_version[self.van_der_waals]
+ )
+
+ smearing: SmearingModel = self.get_model("smearing") # type: ignore
+ if self.electronic_type == "metal":
+ # smearing type setting
+ parameters["pw"]["parameters"]["SYSTEM"]["smearing"] = smearing.type
+ # smearing degauss setting
+ parameters["pw"]["parameters"]["SYSTEM"]["degauss"] = smearing.degauss
+
+ magnetization: MagnetizationModel = self.get_model("magnetization") # type: ignore
+ if self.spin_type == "collinear":
+ parameters["initial_magnetic_moments"] = magnetization.moments
+
+ # Set tot_magnetization for collinear simulations.
+ if self.spin_type == "collinear":
+ # Conditions for metallic systems.
+ # Select the magnetization type and set the value if override is True
+ if self.electronic_type == "metal" and self.override:
+ if magnetization.type == "tot_magnetization":
+ parameters["pw"]["parameters"]["SYSTEM"]["tot_magnetization"] = (
+ magnetization.total
+ )
+ else:
+ parameters["initial_magnetic_moments"] = magnetization.moments
+ # Conditions for insulator systems. Default value is 0.0
+ elif self.electronic_type == "insulator":
+ parameters["pw"]["parameters"]["SYSTEM"]["tot_magnetization"] = (
+ magnetization.total
+ )
+
+ # Spin-Orbit calculation
+ if self.spin_orbit == "soc":
+ parameters["pw"]["parameters"]["SYSTEM"]["lspinorb"] = True
+ parameters["pw"]["parameters"]["SYSTEM"]["noncolin"] = True
+ parameters["pw"]["parameters"]["SYSTEM"]["nspin"] = 4
+
+ return parameters
+
+ def set_model_state(self, parameters):
+ pseudos: PseudosModel = self.get_model("pseudos") # type: ignore
+ if "pseudo_family" in parameters:
+ pseudo_family = PseudoFamily.from_string(parameters["pseudo_family"])
+ library = pseudo_family.library
+ accuracy = pseudo_family.accuracy
+ pseudos.library = f"{library} {accuracy}"
+ pseudos.functional = pseudo_family.functional
+ pseudos.family = parameters["pseudo_family"]
+
+ if "pseudos" in parameters["pw"]:
+ pseudos.dictionary = parameters["pw"]["pseudos"]
+ pseudos.ecutwfc = parameters["pw"]["parameters"]["SYSTEM"]["ecutwfc"]
+ pseudos.ecutrho = parameters["pw"]["parameters"]["SYSTEM"]["ecutrho"]
+
+ self.kpoints_distance = parameters.get("kpoints_distance", 0.15)
+
+ if (pw_parameters := parameters.get("pw", {}).get("parameters")) is not None:
+ self._set_pw_parameters(pw_parameters)
+
+ magnetization: MagnetizationModel = self.get_model("magnetization") # type: ignore
+ if magnetic_moments := parameters.get("initial_magnetic_moments"):
+ if isinstance(magnetic_moments, (int, float)):
+ magnetic_moments = [magnetic_moments]
+ if isinstance(magnetic_moments, list):
+ magnetic_moments = dict(
+ zip(
+ self.input_structure.get_kind_names(),
+ magnetic_moments,
+ )
+ )
+ magnetization.moments = magnetic_moments
+
+ hubbard: HubbardModel = self.get_model("hubbard") # type: ignore
+ if parameters.get("hubbard_parameters"):
+ hubbard.is_active = True
+ hubbard.parameters = parameters["hubbard_parameters"]["hubbard_u"]
+ starting_ns_eigenvalue = (
+ parameters.get("pw", {})
+ .get("parameters", {})
+ .get("SYSTEM", {})
+ .get("starting_ns_eigenvalue")
+ )
+ if starting_ns_eigenvalue is not None:
+ hubbard.set_active_eigenvalues(starting_ns_eigenvalue)
+
+ def reset(self):
+ with self.hold_trait_notifications():
+ self.total_charge = self._get_default("total_charge")
+ self.van_der_waals = self._get_default("van_der_waals")
+ self.forc_conv_thr = self._get_default("forc_conv_thr")
+ self.forc_conv_thr_step = self._get_default("forc_conv_thr_step")
+ self.etot_conv_thr = self._get_default("etot_conv_thr")
+ self.etot_conv_thr_step = self._get_default("etot_conv_thr_step")
+ self.scf_conv_thr = self._get_default("scf_conv_thr")
+ self.scf_conv_thr_step = self._get_default("scf_conv_thr_step")
+ self.electron_maxstep = self._get_default("electron_maxstep")
+ self.spin_orbit = self._get_default("spin_orbit")
+ self.kpoints_distance = self._get_default("kpoints_distance")
+ self.override = self._get_default("override")
+
+ def _get_default(self, trait):
+ return self._defaults.get(trait, self.traits()[trait].default_value)
+
+ def _link_model(self, model: AdvancedSubModel):
+ ipw.dlink(
+ (self, "loaded_from_process"),
+ (model, "loaded_from_process"),
+ )
+ ipw.dlink(
+ (self, "override"),
+ (model, "override"),
+ )
+ model.observe(
+ self._on_any_change,
+ tl.All,
+ )
+ for trait in model.dependencies:
+ ipw.dlink(
+ (self, trait),
+ (model, trait),
+ )
+
+ def _update_kpoints_mesh(self, _=None):
+ if not self.has_structure:
+ mesh_grid = ""
+ elif self.kpoints_distance > 0:
+ mesh = create_kpoints_from_distance.process_class._func(
+ self.input_structure,
+ orm.Float(self.kpoints_distance),
+ orm.Bool(False),
+ )
+ mesh_grid = f"Mesh {mesh.get_kpoints_mesh()[0]!s}"
+ else:
+ mesh_grid = "Please select a number higher than 0.0"
+ self._defaults["mesh_grid"] = mesh_grid
+ self.mesh_grid = mesh_grid
+
+ def _update_kpoints_distance(self, parameters):
+ kpoints_distance = parameters["kpoints_distance"] if self.has_pbc else 100.0
+ self._defaults["kpoints_distance"] = kpoints_distance
+ self.kpoints_distance = self._defaults["kpoints_distance"]
+
+ def _update_thresholds(self, parameters):
+ num_atoms = len(self.input_structure.sites) if self.input_structure else 1
+
+ etot_value = num_atoms * parameters["meta_parameters"]["etot_conv_thr_per_atom"]
+ self._set_value_and_step("etot_conv_thr", etot_value)
+ self.etot_conv_thr = self._defaults["etot_conv_thr"]
+ self.etot_conv_thr_step = self._defaults["etot_conv_thr_step"]
+
+ scf_value = num_atoms * parameters["meta_parameters"]["conv_thr_per_atom"]
+ self._set_value_and_step("scf_conv_thr", scf_value)
+ self.scf_conv_thr = self._defaults["scf_conv_thr"]
+ self.scf_conv_thr_step = self._defaults["scf_conv_thr_step"]
+
+ forc_value = parameters["pw"]["parameters"]["CONTROL"]["forc_conv_thr"]
+ self._set_value_and_step("forc_conv_thr", forc_value)
+ self.forc_conv_thr = self._defaults["forc_conv_thr"]
+ self.forc_conv_thr_step = self._defaults["forc_conv_thr_step"]
+
+ def _set_value_and_step(self, attribute, value):
+ self._defaults[attribute] = value
+ if value != 0:
+ order_of_magnitude = np.floor(np.log10(abs(value)))
+ step = 10 ** (order_of_magnitude - 1)
+ else:
+ step = 0.1
+ self._defaults[f"{attribute}_step"] = step
+
+ def _set_pw_parameters(self, pw_parameters):
+ system_params = pw_parameters.get("SYSTEM", {})
+ control_params = pw_parameters.get("CONTROL", {})
+ electron_params = pw_parameters.get("ELECTRONS", {})
+
+ self.forc_conv_thr = control_params.get("forc_conv_thr", 0.0)
+ self.etot_conv_thr = control_params.get("etot_conv_thr", 0.0)
+ self.scf_conv_thr = electron_params.get("conv_thr", 0.0)
+ self.electron_maxstep = electron_params.get("electron_maxstep", 80)
+
+ self.total_charge = system_params.get("tot_charge", 0)
+ self.spin_orbit = "soc" if "lspinorb" in system_params else "wo_soc"
+
+ self.van_der_waals = self.dftd3_version.get(
+ system_params.get("dftd3_version"),
+ system_params.get("vdw_corr", "none"),
+ )
+
+ smearing: SmearingModel = self.get_model("smearing") # type: ignore
+ if "degauss" in system_params:
+ smearing.degauss = system_params["degauss"]
+
+ if "smearing" in system_params:
+ smearing.type = system_params["smearing"]
+
+ magnetization: MagnetizationModel = self.get_model("magnetization") # type: ignore
+ if "tot_magnetization" in system_params:
+ magnetization.type = "tot_magnetization"
diff --git a/src/aiidalab_qe/app/configuration/advanced/pseudos/__init__.py b/src/aiidalab_qe/app/configuration/advanced/pseudos/__init__.py
new file mode 100644
index 000000000..799a9e1d8
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/advanced/pseudos/__init__.py
@@ -0,0 +1,8 @@
+from .model import PseudosModel
+from .pseudos import PseudoSettings, PseudoUploadWidget
+
+__all__ = [
+ "PseudosModel",
+ "PseudoSettings",
+ "PseudoUploadWidget",
+]
diff --git a/src/aiidalab_qe/app/configuration/advanced/pseudos/model.py b/src/aiidalab_qe/app/configuration/advanced/pseudos/model.py
new file mode 100644
index 000000000..4907d2cf6
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/advanced/pseudos/model.py
@@ -0,0 +1,314 @@
+from __future__ import annotations
+
+from copy import deepcopy
+
+import traitlets as tl
+from aiida_pseudo.common.units import U
+
+from aiida import orm
+from aiida.common import exceptions
+from aiida.plugins import GroupFactory
+from aiida_quantumespresso.workflows.pw.base import PwBaseWorkChain
+from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS
+from aiidalab_qe.common.mixins import HasInputStructure
+from aiidalab_qe.setup.pseudos import PSEUDODOJO_VERSION, SSSP_VERSION, PseudoFamily
+
+from ..subsettings import AdvancedSubModel
+
+SsspFamily = GroupFactory("pseudo.family.sssp")
+PseudoDojoFamily = GroupFactory("pseudo.family.pseudo_dojo")
+CutoffsPseudoPotentialFamily = GroupFactory("pseudo.family.cutoffs")
+
+DEFAULT: dict = DEFAULT_PARAMETERS # type: ignore
+
+
+class PseudosModel(AdvancedSubModel, HasInputStructure):
+ dependencies = [
+ "input_structure",
+ "protocol",
+ "spin_orbit",
+ ]
+
+ protocol = tl.Unicode()
+ spin_orbit = tl.Unicode()
+ override = tl.Bool()
+
+ dictionary = tl.Dict(
+ key_trait=tl.Unicode(), # kind name
+ value_trait=tl.Unicode(), # pseudopotential node uuid
+ default_value={},
+ )
+ family = tl.Unicode(
+ "/".join(
+ [
+ DEFAULT["advanced"]["pseudo_family"]["library"],
+ str(DEFAULT["advanced"]["pseudo_family"]["version"]),
+ DEFAULT["advanced"]["pseudo_family"]["functional"],
+ DEFAULT["advanced"]["pseudo_family"]["accuracy"],
+ ]
+ )
+ )
+ functional = tl.Unicode(DEFAULT["advanced"]["pseudo_family"]["functional"])
+ functional_options = tl.List(
+ trait=tl.Unicode(),
+ default_value=[
+ "PBE",
+ "PBEsol",
+ ],
+ )
+ library = tl.Unicode(
+ " ".join(
+ [
+ DEFAULT["advanced"]["pseudo_family"]["library"],
+ DEFAULT["advanced"]["pseudo_family"]["accuracy"],
+ ]
+ )
+ )
+ library_options = tl.List(
+ trait=tl.Unicode(),
+ default_value=[
+ "SSSP efficiency",
+ "SSSP precision",
+ "PseudoDojo standard",
+ "PseudoDojo stringent",
+ ],
+ )
+ cutoffs = tl.List(
+ trait=tl.List(tl.Float()), # [[ecutwfc values], [ecutrho values]]
+ default_value=[[0.0], [0.0]],
+ )
+ ecutwfc = tl.Float()
+ ecutrho = tl.Float()
+ status_message = tl.Unicode("")
+
+ PSEUDO_HELP_SOC = """
+
+ Spin-orbit coupling (SOC) calculations are supported exclusively with
+ PseudoDojo pseudopotentials. PseudoDojo offers these pseudopotentials
+ in two versions: standard and stringent. Here, we utilize the FR
+ (fully relativistic) type from PseudoDojo. Please ensure you choose
+ appropriate cutoff values for your calculations.
+
+ """
+
+ PSEUDO_HELP_WO_SOC = """
+
+ If you are unsure, select 'SSSP efficiency', which for most
+ calculations will produce sufficiently accurate results at
+ comparatively small computational costs. If your calculations require a
+ higher accuracy, select 'SSSP accuracy' or 'PseudoDojo stringent',
+ which will be computationally more expensive. SSSP is the standard
+ solid-state pseudopotentials. The PseudoDojo used here has the SR
+ relativistic type.
+
+ """
+
+ family_help_message = tl.Unicode(PSEUDO_HELP_WO_SOC)
+
+ def update(self, specific=""): # noqa: ARG002
+ with self.hold_trait_notifications():
+ if not self.has_structure:
+ self._defaults |= {
+ "dictionary": {},
+ "cutoffs": [[0.0], [0.0]],
+ }
+ else:
+ self.update_default_pseudos()
+ self.update_default_cutoffs()
+ self.update_family_parameters()
+ self.update_family()
+
+ def update_default_pseudos(self):
+ if self.loaded_from_process:
+ return
+
+ pseudos = {}
+ self.status_message = ""
+
+ try:
+ pseudo_family = self._get_pseudo_family_from_database()
+ pseudos = pseudo_family.get_pseudos(structure=self.input_structure)
+ except ValueError as exception:
+ self.status_message = f"""
+
+ ERROR: {exception!s}
+
+ """
+ return
+
+ self._defaults["dictionary"] = {
+ kind: pseudo.uuid for kind, pseudo in pseudos.items()
+ }
+ self.dictionary = self._get_default_dictionary()
+
+ def update_default_cutoffs(self):
+ """Update wavefunction and density cutoffs from pseudo family."""
+ if self.loaded_from_process:
+ return
+
+ kinds = []
+ self.status_message = ""
+
+ try:
+ pseudo_family = self._get_pseudo_family_from_database()
+ current_unit = pseudo_family.get_cutoffs_unit()
+ cutoff_dict = pseudo_family.get_cutoffs()
+ except exceptions.NotExistent:
+ self.status_message = f"""
+
+ ERROR: required pseudo family `{self.family}` is
+ not installed. Please use `aiida-pseudo install` to install
+ it."
+
+ """
+ except ValueError as exception:
+ self.status_message = f"""
+
+ ERROR: failed to obtain recommended cutoffs for pseudos
+ `{pseudo_family}`: {exception}
+
+ """
+ else:
+ kinds = self.input_structure.kinds if self.input_structure else []
+
+ ecutwfc_list = []
+ ecutrho_list = []
+ for kind in kinds:
+ cutoff = cutoff_dict.get(kind.symbol, {})
+ ecutrho, ecutwfc = (
+ U.Quantity(v, current_unit).to("Ry").to_tuple()[0]
+ for v in cutoff.values()
+ )
+ ecutwfc_list.append(ecutwfc)
+ ecutrho_list.append(ecutrho)
+
+ self._defaults["cutoffs"] = [ecutwfc_list or [0.0], ecutrho_list or [0.0]]
+ self.cutoffs = self._get_default_cutoffs()
+
+ def update_library_options(self):
+ if self.loaded_from_process:
+ return
+ if self.spin_orbit == "soc":
+ library_options = [
+ "PseudoDojo standard",
+ "PseudoDojo stringent",
+ ]
+ self.family_help_message = self.PSEUDO_HELP_SOC
+ else:
+ library_options = [
+ "SSSP efficiency",
+ "SSSP precision",
+ "PseudoDojo standard",
+ "PseudoDojo stringent",
+ ]
+ self.family_help_message = self.PSEUDO_HELP_WO_SOC
+ self._defaults["library_options"] = library_options
+ self.library_options = self._defaults["library_options"]
+
+ self.update_family_parameters()
+
+ def update_family_parameters(self):
+ if self.loaded_from_process:
+ return
+ if self.spin_orbit == "soc":
+ if self.protocol in ["fast", "moderate"]:
+ pseudo_family_string = "PseudoDojo/0.4/PBE/FR/standard/upf"
+ else:
+ pseudo_family_string = "PseudoDojo/0.4/PBE/FR/stringent/upf"
+ else:
+ pseudo_family_string = PwBaseWorkChain.get_protocol_inputs(self.protocol)[
+ "pseudo_family"
+ ]
+
+ pseudo_family = PseudoFamily.from_string(pseudo_family_string)
+
+ self._defaults["library"] = f"{pseudo_family.library} {pseudo_family.accuracy}"
+ self._defaults["functional"] = pseudo_family.functional
+
+ with self.hold_trait_notifications():
+ self.library = self._defaults["library"]
+ self.functional = self._defaults["functional"]
+
+ def update_family(self):
+ if self.loaded_from_process:
+ return
+ library, accuracy = self.library.split()
+ functional = self.functional
+ # XXX (jusong.yu): a validator is needed to check the family string is
+ # consistent with the list of pseudo families defined in the setup_pseudos.py
+ if library == "PseudoDojo":
+ if self.spin_orbit == "soc":
+ pseudo_family_string = (
+ f"PseudoDojo/{PSEUDODOJO_VERSION}/{functional}/FR/{accuracy}/upf"
+ )
+ else:
+ pseudo_family_string = (
+ f"PseudoDojo/{PSEUDODOJO_VERSION}/{functional}/SR/{accuracy}/upf"
+ )
+ elif library == "SSSP":
+ pseudo_family_string = f"SSSP/{SSSP_VERSION}/{functional}/{accuracy}"
+ else:
+ raise ValueError(
+ f"Unknown pseudo family parameters: {library} | {accuracy}"
+ )
+
+ self._defaults["family"] = pseudo_family_string
+ self.family = self._defaults["family"]
+
+ def reset(self):
+ with self.hold_trait_notifications():
+ self.dictionary = self._get_default("dictionary")
+ self.cutoffs = self._get_default("cutoffs")
+ self.library_options = self._get_default("library_options")
+ self.library = self._get_default("library")
+ self.functional = self._get_default("functional")
+ self.functional_options = self._get_default("functional_options")
+ self.family = self._get_default("family")
+ self.family_help_message = self._get_default("family_help_message")
+ self.status_message = self._get_default("status_message")
+
+ def _get_default(self, trait):
+ if trait == "dictionary":
+ return deepcopy(self._defaults.get(trait, {}))
+ if trait == "cutoffs":
+ return deepcopy(self._defaults.get(trait, [[0.0], [0.0]]))
+ if trait == "functional_options":
+ return self._defaults.get(
+ trait,
+ [
+ "PBE",
+ "PBEsol",
+ ],
+ )
+ if trait == "library_options":
+ return self._defaults.get(
+ trait,
+ [
+ "SSSP efficiency",
+ "SSSP precision",
+ "PseudoDojo standard",
+ "PseudoDojo stringent",
+ ],
+ )
+ return self._defaults.get(trait, self.traits()[trait].default_value)
+
+ def _get_pseudo_family_from_database(self):
+ """Get the pseudo family from the database."""
+ return (
+ orm.QueryBuilder()
+ .append(
+ (
+ PseudoDojoFamily,
+ SsspFamily,
+ CutoffsPseudoPotentialFamily,
+ ),
+ filters={"label": self.family},
+ )
+ .one()[0]
+ )
+
+ def _get_default_dictionary(self):
+ return deepcopy(self._defaults["dictionary"])
+
+ def _get_default_cutoffs(self):
+ return deepcopy(self._defaults["cutoffs"])
diff --git a/src/aiidalab_qe/app/configuration/advanced/pseudos/pseudos.py b/src/aiidalab_qe/app/configuration/advanced/pseudos/pseudos.py
new file mode 100644
index 000000000..012bb3b5e
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/advanced/pseudos/pseudos.py
@@ -0,0 +1,430 @@
+from __future__ import annotations
+
+import io
+
+import ipywidgets as ipw
+import traitlets as tl
+
+from aiida import orm
+from aiida.plugins import DataFactory, GroupFactory
+from aiidalab_qe.common.widgets import LoadingWidget
+from aiidalab_widgets_base.utils import StatusHTML
+
+from ..subsettings import AdvancedSubSettings
+from .model import PseudosModel
+
+UpfData = DataFactory("pseudo.upf")
+SsspFamily = GroupFactory("pseudo.family.sssp")
+PseudoDojoFamily = GroupFactory("pseudo.family.pseudo_dojo")
+CutoffsPseudoPotentialFamily = GroupFactory("pseudo.family.cutoffs")
+
+
+class PseudoSettings(AdvancedSubSettings[PseudosModel]):
+ identifier = "pseudos"
+
+ def __init__(self, model: PseudosModel, **kwargs):
+ super().__init__(model, **kwargs)
+
+ self._model.observe(
+ self._on_input_structure_change,
+ "input_structure",
+ )
+ self._model.observe(
+ self._on_protocol_change,
+ "protocol",
+ )
+ self._model.observe(
+ self._on_spin_orbit_change,
+ "spin_orbit",
+ )
+ self._model.observe(
+ self._on_family_parameters_change,
+ ["library", "functional"],
+ )
+ self._model.observe(
+ self._on_family_change,
+ "family",
+ )
+
+ ipw.dlink(
+ (self._model, "cutoffs"),
+ (self._model, "ecutwfc"),
+ lambda cutoffs: max(cutoffs[0]),
+ )
+ ipw.dlink(
+ (self._model, "cutoffs"),
+ (self._model, "ecutrho"),
+ lambda cutoffs: max(cutoffs[1]),
+ )
+
+ def render(self):
+ if self.rendered:
+ return
+
+ self.family_prompt = ipw.HTML()
+
+ self.family_help = ipw.HTML()
+ ipw.dlink(
+ (self._model, "family_help_message"),
+ (self.family_help, "value"),
+ )
+
+ self.functional_prompt = ipw.HTML("""
+
+ Exchange-correlation functional
+
+ """)
+
+ self.functional_help = ipw.HTML("""
+
+ The exchange-correlation energy is calculated using this functional. We
+ currently provide support for two well-established generalized gradient
+ approximation (GGA) functionals: PBE and PBEsol.
+
+ """)
+
+ self.functional = ipw.Dropdown(style={"description_width": "initial"})
+ ipw.dlink(
+ (self._model, "functional_options"),
+ (self.functional, "options"),
+ )
+ ipw.link(
+ (self._model, "functional"),
+ (self.functional, "value"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.functional, "disabled"),
+ lambda override: not override,
+ )
+
+ self.library = ipw.ToggleButtons(layout=ipw.Layout(max_width="80%"))
+ ipw.dlink(
+ (self._model, "library_options"),
+ (self.library, "options"),
+ )
+ ipw.link(
+ (self._model, "library"),
+ (self.library, "value"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.library, "disabled"),
+ lambda override: not override,
+ )
+
+ self.setter_widget_helper = ipw.HTML("""
+
+ The pseudopotential for each kind of atom in the structure can be
+ custom set. The default pseudopotential and cutoffs are get from
+ the pseudo family. The cutoffs used for the calculation are the
+ maximum of the default from all pseudopotentials and can be custom
+ set.
+
+ """)
+
+ self.setter_widget = ipw.VBox()
+
+ self._status_message = StatusHTML(clear_after=20)
+ ipw.dlink(
+ (self._model, "status_message"),
+ (self._status_message, "message"),
+ )
+
+ self.cutoff_helper = ipw.HTML("""
+
+ Please set the cutoffs for the calculation. The default cutoffs are get
+ from the pseudo family.
+
+ """)
+ self.ecutwfc = ipw.FloatText(
+ description="Wavefunction cutoff (Ry)",
+ style={"description_width": "initial"},
+ )
+ ipw.link(
+ (self._model, "ecutwfc"),
+ (self.ecutwfc, "value"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.ecutwfc, "disabled"),
+ lambda override: not override,
+ )
+ self.ecutrho = ipw.FloatText(
+ description="Charge density cutoff (Ry)",
+ style={"description_width": "initial"},
+ )
+ ipw.link(
+ (self._model, "ecutrho"),
+ (self.ecutrho, "value"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.ecutrho, "disabled"),
+ lambda override: not override,
+ )
+
+ self.children = [
+ ipw.HTML("""
+
+
Accuracy and precision
+
+ """),
+ ipw.HBox(
+ children=[
+ ipw.HTML(
+ """
+
+ The exchange-correlation functional and pseudopotential
+ library is set by the protocol configured in the
+ "Workflow" tab. Here you can override the defaults if
+ desired.
+
+ """,
+ layout=ipw.Layout(max_width="60%"),
+ ),
+ ],
+ layout=ipw.Layout(height="50px", justify_content="space-between"),
+ ),
+ ipw.HBox(
+ [
+ ipw.VBox(
+ children=[
+ self.functional_prompt,
+ self.functional,
+ self.functional_help,
+ ],
+ layout=ipw.Layout(max_width="40%"),
+ ),
+ ipw.VBox(
+ children=[
+ self.family_prompt,
+ self.library,
+ self.family_help,
+ ],
+ layout=ipw.Layout(max_width="60%"),
+ ),
+ ]
+ ),
+ self.setter_widget_helper,
+ self.setter_widget,
+ self.cutoff_helper,
+ ipw.HBox(
+ children=[
+ self.ecutwfc,
+ self.ecutrho,
+ ],
+ ),
+ self._status_message,
+ ]
+
+ self.rendered = True
+
+ self.refresh(specific="widgets")
+
+ def _on_input_structure_change(self, _):
+ self.refresh(specific="structure")
+
+ def _on_protocol_change(self, _):
+ self.refresh(specific="protocol")
+
+ def _on_spin_orbit_change(self, _):
+ self._model.update_library_options()
+
+ def _on_family_parameters_change(self, _):
+ self._model.update_family()
+
+ def _on_family_change(self, _):
+ self._update_family_link()
+ self._model.update_default_pseudos()
+ self._model.update_default_cutoffs()
+
+ def _update(self, specific=""):
+ if self.updated:
+ return
+ self._show_loading()
+ if not self._model.loaded_from_process or specific and specific != "widgets":
+ self._model.update(specific)
+ self._build_setter_widgets()
+ self._model.update_library_options()
+ self._update_family_link()
+ self.updated = True
+
+ def _update_family_link(self):
+ if not self.rendered:
+ return
+
+ library, accuracy = self._model.library.split()
+ if library == "SSSP":
+ pseudo_family_link = (
+ f"https://www.materialscloud.org/discover/sssp/table/{accuracy}"
+ )
+ else:
+ pseudo_family_link = "http://www.pseudo-dojo.org/"
+
+ self.family_prompt.value = f"""
+
+ """
+
+ def _show_loading(self):
+ if self.rendered:
+ self.setter_widget.children = [self.loading_message]
+
+ def _build_setter_widgets(self):
+ if not self.rendered:
+ return
+
+ children = []
+
+ kinds = self._model.input_structure.kinds if self._model.input_structure else []
+
+ for index, kind in enumerate(kinds):
+ upload_widget = PseudoUploadWidget(kind_name=kind.name)
+ pseudo_link = ipw.link(
+ (self._model, "dictionary"),
+ (upload_widget, "pseudo"),
+ [
+ lambda dictionary, kind_name=kind.name: orm.load_node(
+ dictionary.get(kind_name)
+ ),
+ lambda pseudo, kind_name=kind.name: {
+ **self._model.dictionary,
+ kind_name: pseudo.uuid,
+ },
+ ],
+ )
+ pseudo_override_link = ipw.dlink(
+ (self._model, "override"),
+ (upload_widget, "override"),
+ )
+ cutoffs_link = ipw.dlink(
+ (self._model, "cutoffs"),
+ (upload_widget, "cutoffs"),
+ lambda cutoffs, index=index: [cutoffs[0][index], cutoffs[1][index]]
+ if len(cutoffs[0]) > index
+ else [0.0, 0.0],
+ )
+ upload_widget.render()
+
+ self.links.extend(
+ [
+ pseudo_link,
+ pseudo_override_link,
+ cutoffs_link,
+ *upload_widget.links,
+ ]
+ )
+
+ children.append(upload_widget)
+
+ self.setter_widget.children = children
+
+
+# TODO implement/improve MVC in this widget
+class PseudoUploadWidget(ipw.HBox):
+ """Class that allows to upload pseudopotential from user's computer."""
+
+ override = tl.Bool(False)
+
+ pseudo = tl.Instance(UpfData, allow_none=True)
+ cutoffs = tl.List(tl.Float(), [])
+ error_message = tl.Unicode(allow_none=True)
+
+ def __init__(self, kind_name, **kwargs):
+ super().__init__(
+ children=[LoadingWidget("Loading pseudopotential uploader")],
+ **kwargs,
+ )
+
+ self.kind_name = kind_name
+
+ self.rendered = False
+
+ def render(self):
+ if self.rendered:
+ return
+
+ self.pseudo_text = ipw.Text(description=self.kind_name)
+ pseudo_link = ipw.dlink(
+ (self, "pseudo"),
+ (self.pseudo_text, "value"),
+ lambda pseudo: pseudo.filename if pseudo else "",
+ )
+ pseudo_override_link = ipw.dlink(
+ (self, "override"),
+ (self.pseudo_text, "disabled"),
+ lambda override: not override,
+ )
+ self.file_upload = ipw.FileUpload(
+ description="Upload",
+ multiple=False,
+ )
+ upload_link = ipw.dlink(
+ (self, "override"),
+ (self.file_upload, "disabled"),
+ lambda override: not override,
+ )
+ self.file_upload.observe(self._on_file_upload, "value")
+
+ cutoffs_message_template = """
+
+ Recommended ecutwfc: {ecutwfc} Ry ecutrho: {ecutrho} Ry
+
+ """
+
+ self.cutoff_message = ipw.HTML()
+ cutoff_link = ipw.dlink(
+ (self, "cutoffs"),
+ (self.cutoff_message, "value"),
+ lambda cutoffs: cutoffs_message_template.format(
+ ecutwfc=cutoffs[0] if len(cutoffs) else "not set",
+ ecutrho=cutoffs[1] if len(cutoffs) else "not set",
+ ),
+ )
+
+ self.links = [
+ pseudo_link,
+ pseudo_override_link,
+ upload_link,
+ cutoff_link,
+ ]
+
+ self.error_message = None
+
+ self.children = [
+ self.pseudo_text,
+ self.file_upload,
+ self.cutoff_message,
+ ]
+
+ self.rendered = True
+
+ def _on_file_upload(self, change=None):
+ """When file upload button is pressed."""
+ filename, item = next(iter(change["new"].items()))
+ content = item["content"]
+
+ # Order matters make sure when pseudo change
+ # the pseudo_filename is set
+ with self.hold_trait_notifications():
+ self.pseudo = UpfData(io.BytesIO(content), filename=filename)
+ self.pseudo.store()
+
+ # check if element is matched with the pseudo
+ element = "".join([i for i in self.kind_name if not i.isdigit()])
+ if element != self.pseudo.element:
+ self.error_message = f""" ERROR: Element {self.kind_name} is not matched with the pseudo {self.pseudo.element}
"""
+ self._reset()
+ else:
+ self.pseudo_text.value = filename
+
+ def _reset(self):
+ """Reset the widget to the initial state."""
+ self.pseudo = None
+ self.cutoffs = []
diff --git a/src/aiidalab_qe/app/configuration/advanced/smearing/__init__.py b/src/aiidalab_qe/app/configuration/advanced/smearing/__init__.py
new file mode 100644
index 000000000..598982922
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/advanced/smearing/__init__.py
@@ -0,0 +1,7 @@
+from .model import SmearingModel
+from .smearing import SmearingSettings
+
+__all__ = [
+ "SmearingModel",
+ "SmearingSettings",
+]
diff --git a/src/aiidalab_qe/app/configuration/advanced/smearing/model.py b/src/aiidalab_qe/app/configuration/advanced/smearing/model.py
new file mode 100644
index 000000000..b592e59ff
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/advanced/smearing/model.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+import traitlets as tl
+
+from aiida_quantumespresso.workflows.pw.base import PwBaseWorkChain
+
+from ..subsettings import AdvancedSubModel
+
+
+class SmearingModel(AdvancedSubModel):
+ dependencies = [
+ "protocol",
+ ]
+
+ protocol = tl.Unicode()
+ override = tl.Bool()
+
+ type_options = tl.List(
+ trait=tl.Unicode(),
+ default_value=[
+ "cold",
+ "gaussian",
+ "fermi-dirac",
+ "methfessel-paxton",
+ ],
+ )
+ type = tl.Unicode("cold")
+ degauss = tl.Float(0.01)
+
+ def update(self, specific=""): # noqa: ARG002
+ parameters = (
+ PwBaseWorkChain.get_protocol_inputs(self.protocol)
+ .get("pw", {})
+ .get("parameters", {})
+ .get("SYSTEM", {})
+ )
+ self._defaults |= {
+ "type": parameters["smearing"],
+ "degauss": parameters["degauss"],
+ }
+ with self.hold_trait_notifications():
+ self.type = self._defaults["type"]
+ self.degauss = self._defaults["degauss"]
+
+ def reset(self):
+ with self.hold_trait_notifications():
+ self.type = self._get_default("type")
+ self.degauss = self._get_default("degauss")
+
+ def _get_default(self, trait):
+ return self._defaults.get(trait, self.traits()[trait].default_value)
diff --git a/src/aiidalab_qe/app/configuration/advanced/smearing/smearing.py b/src/aiidalab_qe/app/configuration/advanced/smearing/smearing.py
new file mode 100644
index 000000000..6d723448a
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/advanced/smearing/smearing.py
@@ -0,0 +1,76 @@
+import ipywidgets as ipw
+
+from ..subsettings import AdvancedSubSettings
+from .model import SmearingModel
+
+
+class SmearingSettings(AdvancedSubSettings[SmearingModel]):
+ identifier = "smearing"
+
+ def __init__(self, model: SmearingModel, **kwargs):
+ super().__init__(model, **kwargs)
+
+ self._model.observe(
+ self._on_protocol_change,
+ "protocol",
+ )
+
+ def render(self):
+ if self.rendered:
+ return
+
+ self.smearing = ipw.Dropdown(
+ description="Smearing type:",
+ style={"description_width": "initial"},
+ )
+ ipw.dlink(
+ (self._model, "type_options"),
+ (self.smearing, "options"),
+ )
+ ipw.link(
+ (self._model, "type"),
+ (self.smearing, "value"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.smearing, "disabled"),
+ lambda override: not override,
+ )
+
+ self.degauss = ipw.FloatText(
+ step=0.005,
+ description="Smearing width (Ry):",
+ style={"description_width": "initial"},
+ )
+ ipw.link(
+ (self._model, "degauss"),
+ (self.degauss, "value"),
+ )
+ ipw.dlink(
+ (self._model, "override"),
+ (self.degauss, "disabled"),
+ lambda override: not override,
+ )
+
+ self.children = [
+ ipw.HTML("""
+
+ The smearing type and width is set by the chosen protocol.
+ Tick the box to override the default, not advised unless you've
+ mastered smearing effects (click
+ here for a discussion).
+
+ """),
+ ipw.HBox(
+ children=[
+ self.smearing,
+ self.degauss,
+ ]
+ ),
+ ]
+
+ self.rendered = True
+
+ def _on_protocol_change(self, _):
+ self.refresh(specific="protocol")
diff --git a/src/aiidalab_qe/app/configuration/advanced/subsettings.py b/src/aiidalab_qe/app/configuration/advanced/subsettings.py
new file mode 100644
index 000000000..77bc4cf07
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/advanced/subsettings.py
@@ -0,0 +1,110 @@
+import os
+import typing as t
+
+import ipywidgets as ipw
+import traitlets as tl
+
+from aiidalab_qe.common.mvc import Model
+
+
+class AdvancedSubModel(Model):
+ dependencies = []
+
+ loaded_from_process = tl.Bool(False)
+
+ _defaults = {}
+
+ def update(self, specific=""):
+ """Updates the model.
+
+ Parameters
+ ----------
+ `specific` : `str`, optional
+ If provided, specifies the level of update.
+ """
+ raise NotImplementedError()
+
+ def reset(self):
+ """Resets the model to present defaults."""
+ raise NotImplementedError()
+
+
+M = t.TypeVar("M", bound=AdvancedSubModel)
+
+
+class AdvancedSubSettings(ipw.VBox, t.Generic[M]):
+ identifier = "sub"
+
+ def __init__(self, model: M, **kwargs):
+ from aiidalab_qe.common.widgets import LoadingWidget
+
+ self.loading_message = LoadingWidget(f"Loading {self.identifier} settings")
+
+ super().__init__(
+ layout={"justify_content": "space-between", **kwargs.get("layout", {})},
+ children=[self.loading_message],
+ **kwargs,
+ )
+
+ self._model = model
+ self._model.observe(
+ self._on_override_change,
+ "override",
+ )
+
+ self.rendered = False
+ self.updated = False
+
+ self.links = []
+
+ def render(self):
+ raise NotImplementedError()
+
+ def refresh(self, specific=""):
+ """Refreshes the subsettings section.
+
+ Unlinks any linked widgets and updates the model's defaults.
+ Resets the model to these defaults if there is no input structure.
+
+ Parameters
+ ----------
+ `specific` : `str`, optional
+ If provided, specifies the level of refresh.
+ """
+ self.updated = False
+ self._unsubscribe()
+ self._update(specific)
+ if "PYTEST_CURRENT_TEST" in os.environ:
+ # Skip resetting to avoid having to inject a structure when testing
+ return
+ if hasattr(self._model, "input_structure") and not self._model.input_structure:
+ self._reset()
+
+ def _on_override_change(self, change):
+ if not change["new"]:
+ self._reset()
+
+ def _update(self, specific=""):
+ """Updates the model if not yet updated.
+
+ Parameters
+ ----------
+ `specific` : `str`, optional
+ If provided, specifies the level of update.
+ """
+ if self.updated:
+ return
+ if not self._model.loaded_from_process:
+ self._model.update(specific)
+ self.updated = True
+
+ def _unsubscribe(self):
+ """Unlinks any linked widgets."""
+ for link in self.links:
+ link.unlink()
+ self.links.clear()
+
+ def _reset(self):
+ """Resets the model to present defaults."""
+ self.updated = False
+ self._model.reset()
diff --git a/src/aiidalab_qe/app/configuration/basic/__init__.py b/src/aiidalab_qe/app/configuration/basic/__init__.py
new file mode 100644
index 000000000..9ce65d685
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/basic/__init__.py
@@ -0,0 +1,7 @@
+from .model import WorkChainModel
+from .workflow import WorkChainSettings
+
+__all__ = [
+ "WorkChainModel",
+ "WorkChainSettings",
+]
diff --git a/src/aiidalab_qe/app/configuration/basic/model.py b/src/aiidalab_qe/app/configuration/basic/model.py
new file mode 100644
index 000000000..cd9bd90df
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/basic/model.py
@@ -0,0 +1,61 @@
+import traitlets as tl
+
+from aiida import orm
+from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS
+from aiidalab_qe.common.panel import SettingsModel
+
+DEFAULT: dict = DEFAULT_PARAMETERS # type: ignore
+
+
+class WorkChainModel(SettingsModel):
+ dependencies = [
+ "input_structure",
+ ]
+
+ input_structure = tl.Union([tl.Instance(orm.StructureData)], allow_none=True)
+
+ protocol_options = tl.List(
+ trait=tl.Unicode(),
+ default_value=[
+ "fast",
+ "moderate",
+ "precise",
+ ],
+ )
+ protocol = tl.Unicode(DEFAULT["workchain"]["protocol"])
+ spin_type_options = tl.List(
+ trait=tl.List(tl.Unicode()),
+ default_value=[
+ ["Off", "none"],
+ ["On", "collinear"],
+ ],
+ )
+ spin_type = tl.Unicode(DEFAULT["workchain"]["spin_type"])
+ electronic_type_options = tl.List(
+ trait=tl.List(tl.Unicode()),
+ default_value=[
+ ["Metal", "metal"],
+ ["Insulator", "insulator"],
+ ],
+ )
+ electronic_type = tl.Unicode(DEFAULT["workchain"]["electronic_type"])
+
+ include = True
+
+ def get_model_state(self):
+ return {
+ "protocol": self.protocol,
+ "spin_type": self.spin_type,
+ "electronic_type": self.electronic_type,
+ }
+
+ def set_model_state(self, parameters):
+ self.protocol = parameters.get("protocol", self.protocol)
+ self.spin_type = parameters.get("spin_type", self.spin_type)
+ self.electronic_type = parameters.get("electronic_type", self.electronic_type)
+
+ def reset(self):
+ with self.hold_trait_notifications():
+ self.protocol = self.traits()["protocol"].default_value
+ self.spin_type = self.traits()["spin_type"].default_value
+ self.electronic_type = self.traits()["electronic_type"].default_value
diff --git a/src/aiidalab_qe/app/configuration/basic/workflow.py b/src/aiidalab_qe/app/configuration/basic/workflow.py
new file mode 100644
index 000000000..8ed825aa7
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/basic/workflow.py
@@ -0,0 +1,107 @@
+"""Widgets for the submission of bands work chains.
+
+Authors: AiiDAlab team
+"""
+
+import ipywidgets as ipw
+
+from aiidalab_qe.app.configuration.basic.model import WorkChainModel
+from aiidalab_qe.common.panel import SettingsPanel
+
+
+class WorkChainSettings(SettingsPanel[WorkChainModel]):
+ title = "Basic Settings"
+ identifier = "workchain"
+
+ def __init__(self, model: WorkChainModel, **kwargs):
+ super().__init__(model, **kwargs)
+ self._model.observe(
+ self._on_input_structure_change,
+ "input_structure",
+ )
+
+ def render(self):
+ if self.rendered:
+ return
+
+ # SpinType: magnetic properties of material
+ self.spin_type = ipw.ToggleButtons(style={"description_width": "initial"})
+ ipw.dlink(
+ (self._model, "spin_type_options"),
+ (self.spin_type, "options"),
+ )
+ ipw.link(
+ (self._model, "spin_type"),
+ (self.spin_type, "value"),
+ )
+
+ # ElectronicType: electronic properties of material
+ self.electronic_type = ipw.ToggleButtons(style={"description_width": "initial"})
+ ipw.dlink(
+ (self._model, "electronic_type_options"),
+ (self.electronic_type, "options"),
+ )
+ ipw.link(
+ (self._model, "electronic_type"),
+ (self.electronic_type, "value"),
+ )
+
+ # Work chain protocol
+ self.protocol = ipw.ToggleButtons()
+ ipw.dlink(
+ (self._model, "protocol_options"),
+ (self.protocol, "options"),
+ )
+ ipw.link(
+ (self._model, "protocol"),
+ (self.protocol, "value"),
+ )
+
+ self.children = [
+ ipw.HTML("""
+
+ Below you can indicate both if the material should be treated as an
+ insulator or a metal (if in doubt, choose "Metal"), and if it
+ should be studied with magnetization/spin polarization, switch
+ magnetism On or Off (On is at least twice more costly).
+
+ """),
+ ipw.HBox(
+ children=[
+ ipw.Label(
+ "Electronic Type:",
+ layout=ipw.Layout(justify_content="flex-start", width="120px"),
+ ),
+ self.electronic_type,
+ ]
+ ),
+ ipw.HBox(
+ children=[
+ ipw.Label(
+ "Magnetism:",
+ layout=ipw.Layout(justify_content="flex-start", width="120px"),
+ ),
+ self.spin_type,
+ ]
+ ),
+ ipw.HTML("""
+
+
Protocol
+
+ """),
+ ipw.HTML("Select the protocol:", layout=ipw.Layout(flex="1 1 auto")),
+ self.protocol,
+ ipw.HTML("""
+
+ The "moderate" protocol represents a trade-off between accuracy and
+ speed. Choose the "fast" protocol for a faster calculation with
+ less precision and the "precise" protocol to aim at best accuracy
+ (at the price of longer/costlier calculations).
+
+ """),
+ ]
+
+ self.rendered = True
+
+ def _on_input_structure_change(self, _):
+ self.refresh(specific="structure")
diff --git a/src/aiidalab_qe/app/configuration/model.py b/src/aiidalab_qe/app/configuration/model.py
new file mode 100644
index 000000000..8d8f987a8
--- /dev/null
+++ b/src/aiidalab_qe/app/configuration/model.py
@@ -0,0 +1,154 @@
+from __future__ import annotations
+
+import ipywidgets as ipw
+import traitlets as tl
+
+from aiida_quantumespresso.common.types import RelaxType
+from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS
+from aiidalab_qe.common.mixins import (
+ Confirmable,
+ HasInputStructure,
+ HasModels,
+)
+from aiidalab_qe.common.mvc import Model
+from aiidalab_qe.common.panel import SettingsModel
+
+DEFAULT: dict = DEFAULT_PARAMETERS # type: ignore
+
+
+class ConfigurationStepModel(
+ Model,
+ HasModels[SettingsModel],
+ HasInputStructure,
+ Confirmable,
+):
+ relax_type_help = tl.Unicode()
+ relax_type_options = tl.List([DEFAULT["workchain"]["relax_type"]])
+ relax_type = tl.Unicode(DEFAULT["workchain"]["relax_type"])
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self._default_models = {
+ "workchain",
+ "advanced",
+ }
+
+ self.relax_type_help_template = """
+
+ You have {option_count} options:
+
+ (1) Structure as is: perform a self consistent calculation using
+ the structure provided as input.
+
+ (2) Atomic positions: perform a full relaxation of the internal
+ atomic coordinates.
+ {full_relaxation_option}
+
+ """
+
+ def update(self):
+ if self.has_pbc:
+ relax_type_help = self.relax_type_help_template.format(
+ option_count="three",
+ full_relaxation_option=(
+ """
+
+ (3) Full geometry: perform a full relaxation of the internal atomic
+ coordinates and the cell parameters.
+ """
+ ),
+ )
+ relax_type_options = [
+ ("Structure as is", "none"),
+ ("Atomic positions", "positions"),
+ ("Full geometry", "positions_cell"),
+ ]
+ else:
+ relax_type_help = self.relax_type_help_template.format(
+ option_count="two",
+ full_relaxation_option="",
+ )
+ relax_type_options = [
+ ("Structure as is", "none"),
+ ("Atomic positions", "positions"),
+ ]
+ self._defaults = {
+ "relax_type_help": relax_type_help,
+ "relax_type_options": relax_type_options,
+ "relax_type": relax_type_options[-1][-1],
+ }
+ with self.hold_trait_notifications():
+ self.relax_type_help = self._get_default_relax_type_help()
+ self.relax_type_options = self._get_default_relax_type_options()
+ self.relax_type = self._get_default_relax_type()
+
+ def get_model_state(self):
+ parameters = {
+ identifier: model.get_model_state()
+ for identifier, model in self._models.items()
+ if model.include
+ }
+ parameters["workchain"] |= {
+ "relax_type": self.relax_type,
+ "properties": self._get_properties(),
+ }
+ return parameters
+
+ def set_model_state(self, parameters):
+ with self.hold_trait_notifications():
+ workchain_parameters: dict = parameters.get("workchain", {})
+ self.relax_type = workchain_parameters.get("relax_type")
+ properties = set(workchain_parameters.get("properties", []))
+ for identifier, model in self._models.items():
+ model.include = identifier in self._default_models | properties
+ if parameters.get(identifier):
+ model.set_model_state(parameters[identifier])
+ model.loaded_from_process = True
+
+ def reset(self):
+ self.confirmed = False
+ self.relax_type_help = self._get_default_relax_type_help()
+ self.relax_type_options = self._get_default_relax_type_options()
+ self.relax_type = self._get_default_relax_type()
+ for identifier, model in self._models.items():
+ if identifier not in self._default_models:
+ model.include = False
+
+ def _link_model(self, model: SettingsModel):
+ ipw.link(
+ (self, "confirmed"),
+ (model, "confirmed"),
+ )
+ for dependency in model.dependencies:
+ dependency_parts = dependency.split(".")
+ if len(dependency_parts) == 1: # from parent, e.g. input_structure
+ target_model = self
+ trait = dependency
+ else: # from sibling, e.g. workchain.protocol
+ sibling, trait = dependency_parts
+ target_model = self.get_model(sibling)
+ ipw.dlink(
+ (target_model, trait),
+ (model, trait),
+ )
+
+ def _get_properties(self):
+ properties = []
+ for identifier, model in self._models.items():
+ if identifier in self._default_models:
+ continue
+ if model.include:
+ properties.append(identifier)
+ if RelaxType(self.relax_type) is not RelaxType.NONE or not properties:
+ properties.append("relax")
+ return properties
+
+ def _get_default_relax_type_help(self):
+ return self._defaults.get("relax_type_help", "")
+
+ def _get_default_relax_type_options(self):
+ return self._defaults.get("relax_type_options", [""])
+
+ def _get_default_relax_type(self):
+ return self._defaults.get("relax_type", "")
diff --git a/src/aiidalab_qe/app/configuration/pseudos.py b/src/aiidalab_qe/app/configuration/pseudos.py
deleted file mode 100644
index 1d969779f..000000000
--- a/src/aiidalab_qe/app/configuration/pseudos.py
+++ /dev/null
@@ -1,563 +0,0 @@
-from __future__ import annotations
-
-import io
-import re
-
-import ipywidgets as ipw
-import traitlets as tl
-
-from aiida import orm
-from aiida.common import exceptions
-from aiida.plugins import DataFactory, GroupFactory
-from aiida_quantumespresso.workflows.pw.base import PwBaseWorkChain
-from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS
-from aiidalab_qe.setup.pseudos import (
- PSEUDODOJO_VERSION,
- SSSP_VERSION,
- PseudoFamily,
-)
-from aiidalab_widgets_base.utils import StatusHTML
-
-UpfData = DataFactory("pseudo.upf")
-SsspFamily = GroupFactory("pseudo.family.sssp")
-PseudoDojoFamily = GroupFactory("pseudo.family.pseudo_dojo")
-CutoffsPseudoPotentialFamily = GroupFactory("pseudo.family.cutoffs")
-
-
-class PseudoFamilySelector(ipw.VBox):
- title = ipw.HTML(
- """
-
Accuracy and precision
"""
- )
- PSEUDO_HELP_SOC = """
- Spin-orbit coupling (SOC) calculations are supported exclusively with PseudoDojo pseudopotentials.
- PseudoDojo offers these pseudopotentials in two versions: standard and stringent.
- Here, we utilize the FR (fully relativistic) type from PseudoDojo.
- Please ensure you choose appropriate cutoff values for your calculations.
-
"""
-
- PSEUDO_HELP_WO_SOC = """
- If you are unsure, select 'SSSP efficiency', which for
- most calculations will produce sufficiently accurate results at
- comparatively small computational costs. If your calculations require a
- higher accuracy, select 'SSSP accuracy' or 'PseudoDojo stringent', which will be computationally
- more expensive. SSSP is the standard solid-state pseudopotentials.
- The PseudoDojo used here has the SR relativistic type.
"""
-
- description = ipw.HTML(
- """
- The exchange-correlation functional and pseudopotential library is set by
- the protocol configured in the "Workflow" tab. Here you can
- override the defaults if desired.
""",
- layout=ipw.Layout(max_width="60%"),
- )
-
- # XXX: the link is not correct after add pseudo dojo
- pseudo_family_prompt = ipw.HTML(
- """"""
- )
- pseudo_family_help = ipw.HTML(PSEUDO_HELP_WO_SOC)
-
- dft_functional_prompt = ipw.HTML(
- """
-
- Exchange-correlation functional
"""
- )
- dft_functional_help = ipw.HTML(
- """
- The exchange-correlation energy is calculated using this functional. We currently provide support for two
- well-established generalised gradient approximation (GGA) functionals:
- PBE and PBEsol.
"""
- )
- protocol = tl.Unicode(allow_none=True)
- disabled = tl.Bool()
- spin_orbit = tl.Unicode()
-
- # output pseudo family widget which is the string of the pseudo family (of the AiiDA group).
- value = tl.Unicode(allow_none=True)
-
- def __init__(self, **kwargs):
- # Enable manual setting of the pseudopotential family
- self.set_pseudo_family_prompt = ipw.HTML(" Override ")
- self.override = ipw.Checkbox(
- description="",
- indent=False,
- layout=ipw.Layout(max_width="10%"),
- )
- self.set_pseudo_family_box = ipw.HBox(
- [self.set_pseudo_family_prompt, self.override],
- layout=ipw.Layout(max_width="20%"),
- )
- self.show_ui = ipw.Valid(value=True)
- self.override.observe(self.set_show_ui, "value")
- self.override.observe(self.set_text_color, "value")
- self.override.observe(self.set_value, "value")
-
- # the widget for DFT functional selection
- self.dft_functional = ipw.Dropdown(
- options=["PBE", "PBEsol"],
- style={"description_width": "initial"},
- )
- self.dft_functional.observe(self.set_value, "value")
- self.library_selection = ipw.ToggleButtons(
- options=[
- "SSSP efficiency",
- "SSSP precision",
- "PseudoDojo standard",
- "PseudoDojo stringent",
- ],
- layout=ipw.Layout(max_width="80%"),
- )
- self.library_selection.observe(self.set_value, "value")
-
- self.dft_functional_box = ipw.VBox(
- children=[
- self.dft_functional_prompt,
- self.dft_functional,
- self.dft_functional_help,
- ],
- layout=ipw.Layout(max_width="40%"),
- )
- self.pseudo_setup_box = ipw.VBox(
- children=[
- self.pseudo_family_prompt,
- self.library_selection,
- self.pseudo_family_help,
- ],
- layout=ipw.Layout(max_width="60%"),
- **kwargs,
- )
- ipw.dlink((self.show_ui, "value"), (self.library_selection, "disabled"))
- ipw.dlink((self.show_ui, "value"), (self.dft_functional, "disabled"))
-
- super().__init__(
- children=[
- self.title,
- ipw.HBox(
- [self.description, self.set_pseudo_family_box],
- layout=ipw.Layout(height="50px", justify_content="space-between"),
- ),
- ipw.HBox([self.dft_functional_box, self.pseudo_setup_box]),
- ]
- )
- # after the initialization, the protocol is set to the default
- # this will trigger the callback to set the value of widgets to the default
- self._default_protocol = DEFAULT_PARAMETERS["workchain"]["protocol"]
- self.protocol = self._default_protocol
- self.override.value = False
-
- def set_value(self, _=None):
- """The callback when the selection of pseudo family or dft functional is changed.
- Also triggered when the override checkbox is changed.
- This is the only method to set the value of the widget.
- """
- library, accuracy = self.library_selection.value.split()
- functional = self.dft_functional.value
- # XXX (jusong.yu): a validator is needed to check the family string is consistent with the list of pseudo families defined in the setup_pseudos.py
- if library == "PseudoDojo":
- if self.spin_orbit == "soc":
- pseudo_family_string = (
- f"PseudoDojo/{PSEUDODOJO_VERSION}/{functional}/FR/{accuracy}/upf"
- )
- else:
- pseudo_family_string = (
- f"PseudoDojo/{PSEUDODOJO_VERSION}/{functional}/SR/{accuracy}/upf"
- )
- elif library == "SSSP":
- pseudo_family_string = f"SSSP/{SSSP_VERSION}/{functional}/{accuracy}"
- else:
- raise ValueError(
- f"Unknown pseudo family {self.override_protocol_pseudo_family.value}"
- )
-
- self.value = pseudo_family_string
-
- def set_show_ui(self, change):
- self.show_ui.value = not change.new
-
- def set_text_color(self, change):
- opacity = 1.0 if change.new else 0.5
-
- for html in (
- self.pseudo_family_prompt,
- self.pseudo_family_help,
- self.dft_functional_help,
- self.dft_functional_prompt,
- ):
- old_opacity = re.match(
- r"[\s\S]+opacity:([\S]+);[\S\s]+", html.value
- ).groups()[0]
- html.value = html.value.replace(
- f"opacity:{old_opacity};", f"opacity:{opacity};"
- )
-
- def reset(self):
- """Reset the widget to the initial state by reset protocol to default."""
- self.protocol = self._default_protocol
-
- # in case the protocol is not changed, the callback is not triggered
- # so we trigger it explicitly. This will happened when the protocol
- # stay the same while xc selection is changed.
- self._update_settings_from_protocol(self.protocol)
-
- @tl.observe("spin_orbit")
- def _update_library_selection(self, _):
- """Update the library selection according to the spin orbit value."""
- if self.spin_orbit == "soc":
- self.library_selection.options = [
- "PseudoDojo standard",
- "PseudoDojo stringent",
- ]
- self.pseudo_family_help.value = self.PSEUDO_HELP_SOC
- else:
- self.library_selection.options = [
- "SSSP efficiency",
- "SSSP precision",
- "PseudoDojo standard",
- "PseudoDojo stringent",
- ]
- self.pseudo_family_help.value = self.PSEUDO_HELP_WO_SOC
-
- @tl.observe("protocol")
- def _protocol_changed(self, _):
- """Input protocol changed, update the value of widgets."""
- self._update_settings_from_protocol(self.protocol)
-
- def _update_settings_from_protocol(self, protocol):
- """Update the widget values from the given protocol, and trigger the callback."""
- # FIXME: this rely on the aiida-quantumespresso, which is not ideal
-
- if self.spin_orbit == "soc":
- if protocol in ["fast", "moderate"]:
- pseudo_family_string = "PseudoDojo/0.4/PBE/FR/standard/upf"
- else:
- pseudo_family_string = "PseudoDojo/0.4/PBE/FR/stringent/upf"
- else:
- pseudo_family_string = PwBaseWorkChain.get_protocol_inputs(protocol)[
- "pseudo_family"
- ]
-
- pseudo_family = PseudoFamily.from_string(pseudo_family_string)
-
- self.load_from_pseudo_family(pseudo_family)
-
- def load_from_pseudo_family(self, pseudo_family: PseudoFamily):
- """Reload the widget from the given pseudo family string."""
- with self.hold_trait_notifications():
- # will trigger the callback to set the value of widgets
- self.library_selection.value = (
- f"{pseudo_family.library} {pseudo_family.accuracy}"
- )
- self.dft_functional.value = pseudo_family.functional
-
-
-class PseudoSetter(ipw.VBox):
- structure = tl.Instance(klass=orm.StructureData, allow_none=True)
- pseudo_family = tl.Unicode(allow_none=True)
-
- # output pseudos
- pseudos = tl.Dict()
-
- # output cutoffs
- ecutwfc = tl.Float()
- ecutrho = tl.Float()
-
- _default_pseudo_setter_helper_text = """
- Input structure is not set. Please set the structure first.
-
"""
- _update_pseudo_setter_helper_text = """
- The pseudopotential for each kind of atom in the structure can be set customly.
- The default pseudopotential and cutoffs are get from the pseudo family.
- The cutoffs used for the calculation are the maximum of the default from all pseudopotentials
- and can be set customly.
-
"""
-
- _cutoff_setter_helper_text = """
- Please set the cutoffs for the calculation. The default cutoffs are get from the pseudo family.
-
"""
-
- def __init__(
- self,
- structure: orm.StructureData | None = None,
- pseudo_family: str | None = None,
- **kwargs,
- ):
- self.pseudo_setting_widgets = ipw.VBox()
- self._status_message = StatusHTML(clear_after=20)
-
- # the initial cutoffs are set to 0
- self.ecutwfc = 0
- self.ecutrho = 0
-
- self.pseudo_setter_helper = ipw.HTML(self._default_pseudo_setter_helper_text)
- self.cutoff_setter_helper = ipw.HTML(self._cutoff_setter_helper_text)
- self.ecutwfc_setter = ipw.FloatText(
- description="Wavefunction cutoff (Ry)",
- style={"description_width": "initial"},
- )
- self.ecutrho_setter = ipw.FloatText(
- description="Charge density cutoff (Ry)",
- style={"description_width": "initial"},
- )
- self.ecutwfc_setter.observe(self._on_cutoff_change, names="value")
- self.ecutrho_setter.observe(self._on_cutoff_change, names="value")
-
- super().__init__(
- children=[
- self.pseudo_setter_helper,
- self.pseudo_setting_widgets,
- self.cutoff_setter_helper,
- ipw.HBox(
- children=[
- self.ecutwfc_setter,
- self.ecutrho_setter,
- ],
- ),
- self._status_message,
- ],
- **kwargs,
- )
- self._reset()
- with self.hold_trait_notifications():
- self.structure = structure
- self.pseudo_family = pseudo_family
-
- def _on_cutoff_change(self, _=None):
- """Update the cutoffs according to the cutoff widgets"""
- self.ecutwfc = self.ecutwfc_setter.value
- self.ecutrho = self.ecutrho_setter.value
-
- def _reset_cutoff_widgets(self):
- """Reset the cutoff widgets to 0"""
- self.ecutwfc_setter.value = 0
- self.ecutrho_setter.value = 0
-
- def _reset_traitlets(self):
- """Reset the traitlets to the initial state"""
- self.ecutwfc = 0
- self.ecutrho = 0
- self.pseudos = {}
-
- def _reset(self):
- """Reset the pseudo setting widgets according to the structure
- by default the pseudos are get from the pseudo family
- """
- if self.structure is None:
- self._reset_cutoff_widgets()
- self._reset_traitlets()
- self.pseudo_setting_widgets.children = ()
- self.pseudo_setter_helper.value = self._default_pseudo_setter_helper_text
- return
-
- if self.pseudo_family is None:
- # this happened from the beginning when the widget is initialized
- # but also for the case when pseudo family is not provided which
- # won't happened for the real use but may happen for the test
- # so we still generate the pseudo setting widgets
- kinds = self.structure.get_kind_names()
-
- # Reset the traitlets, so the interface is clear setup
- self.pseudo_setting_widgets.children = ()
- self._reset_traitlets()
-
- # loop over the kinds and create the pseudo setting widget
- # (initialized with the pseudo from the family)
- for kind in kinds:
- pseudo_upload_widget = PseudoUploadWidget(kind=kind)
-
- # keep track of the changing of pseudo setting of each kind
- pseudo_upload_widget.observe(self._update_pseudos, ["pseudo"])
- self.pseudo_setting_widgets.children += (pseudo_upload_widget,)
-
- return
-
- try:
- pseudo_family = self._get_pseudos_family(self.pseudo_family)
- except exceptions.NotExistent as exception:
- self._status_message.message = (
- f""" ERROR: {exception!s}
"""
- )
- return
-
- try:
- pseudos = pseudo_family.get_pseudos(structure=self.structure)
- # get cutoffs dict of all elements
- cutoffs = self._get_cutoffs(pseudo_family)
- except ValueError as exception:
- self._status_message.message = f""" ERROR: failed to obtain recommended cutoffs for pseudos `{pseudo_family}`: {exception}
"""
- return
-
- # success get family and cutoffs, set the traitlets accordingly
- # set the recommended cutoffs
- self.pseudos = {kind: pseudo.uuid for kind, pseudo in pseudos.items()}
- self.set_pseudos(self.pseudos, cutoffs)
-
- def _get_pseudos_family(self, pseudo_family: str) -> orm.Group:
- """Get the pseudo family from the database."""
- try:
- pseudo_set = (PseudoDojoFamily, SsspFamily, CutoffsPseudoPotentialFamily)
- pseudo_family = (
- orm.QueryBuilder()
- .append(pseudo_set, filters={"label": pseudo_family})
- .one()[0]
- )
- except exceptions.NotExistent as exception:
- raise exceptions.NotExistent(
- f"required pseudo family `{pseudo_family}` is not installed. Please use `aiida-pseudo install` to"
- "install it."
- ) from exception
-
- return pseudo_family
-
- def _get_cutoffs(self, pseudo_family):
- """Get the cutoffs from the pseudo family."""
- from aiida_pseudo.common.units import U
-
- try:
- cutoffs = pseudo_family.get_cutoffs()
- except ValueError as exception:
- self._status_message.message = f""" ERROR: failed to obtain recommended cutoffs for pseudos `{pseudo_family}`: {exception}
"""
- return
-
- current_unit = pseudo_family.get_cutoffs_unit()
- for element, cutoff in cutoffs.items():
- cutoffs[element] = {
- k: U.Quantity(v, current_unit).to("Ry").to_tuple()[0]
- for k, v in cutoff.items()
- }
-
- return cutoffs
-
- def _create_pseudo_widget(self, kind):
- """The sigle line of pseudo setter widget"""
- return PseudoUploadWidget(kind=kind)
-
- @tl.observe("structure")
- def _structure_change(self, _):
- self._reset()
- self._update_pseudos()
-
- @tl.observe("pseudo_family")
- def _pseudo_family_change(self, _):
- self._reset()
- self._update_pseudos()
-
- def _update_pseudos(self, _=None):
- """Update the pseudos according to the pseudo setting widgets"""
- self._reset_cutoff_widgets()
- for w in self.pseudo_setting_widgets.children:
- if w.error_message is not None:
- self._status_message.message = w.error_message
- return
-
- if w.pseudo is not None:
- self.pseudos[w.kind] = w.pseudo.uuid
- self.pseudo_setter_helper.value = self._update_pseudo_setter_helper_text
-
- with self.hold_trait_notifications():
- self.ecutwfc_setter.value = max(self.ecutwfc, w.ecutwfc)
- self.ecutrho_setter.value = max(self.ecutrho, w.ecutrho)
-
- def set_pseudos(self, pseudos, cutoffs):
- # Reset the traitlets, so the interface is clear setup
- self.pseudo_setting_widgets.children = ()
- self._reset_traitlets()
-
- # loop over the kinds and create the pseudo setting widget
- # (initialized with the pseudo from the family)
- for kind in self.structure.kinds:
- element = kind.symbol
- pseudo = orm.load_node(pseudos.get(kind.name, None))
- _cutoffs = cutoffs.get(element, None) # cutoffs for each element
- pseudo_upload_widget = PseudoUploadWidget(
- kind=kind.name, pseudo=pseudo, cutoffs=_cutoffs
- )
-
- # keep track of the changing of pseudo setting of each kind
- pseudo_upload_widget.observe(
- self._update_pseudos, ["pseudo", "ecutwfc", "ecutrho"]
- )
- self.pseudo_setting_widgets.children += (pseudo_upload_widget,)
- self._update_pseudos()
-
-
-class PseudoUploadWidget(ipw.HBox):
- """Class that allows to upload pseudopotential from user's computer."""
-
- pseudo = tl.Instance(klass=UpfData, allow_none=True)
- kind = tl.Unicode()
- ecutwfc = tl.Float(allow_none=True)
- ecutrho = tl.Float(allow_none=True)
- error_message = tl.Unicode(allow_none=True)
-
- cutoffs_message_template = """
- The recommened ecutwfc: {ecutwfc} Ry
- for ecutrho: {ecutrho} Ry
-
"""
-
- def __init__(
- self,
- kind: str = "",
- pseudo: UpfData | None = None,
- cutoffs: dict | None = None,
- **kwargs,
- ):
- self.kind = kind
- self.file_upload = ipw.FileUpload(
- description="Upload",
- multiple=False,
- )
- self.pseudo_text = ipw.Text(description=kind)
- self.file_upload.observe(self._on_file_upload, names="value")
-
- self._cutoff_message = ipw.HTML(
- self.cutoffs_message_template.format(ecutwfc=0, ecutrho=0)
- )
-
- if pseudo is not None:
- self.pseudo = pseudo
- self.pseudo_text.value = pseudo.filename
-
- self.error_message = None
- super().__init__(
- children=[
- self.pseudo_text,
- self.file_upload,
- self._cutoff_message,
- ],
- **kwargs,
- )
- # set the widget directly to trigger the traitlets set
- if cutoffs is not None:
- self.ecutwfc = cutoffs.get("cutoff_wfc", None)
- self.ecutrho = cutoffs.get("cutoff_rho", None)
- self._cutoff_message.value = self.cutoffs_message_template.format(
- ecutwfc=self.ecutwfc or "not set", ecutrho=self.ecutrho or "not set"
- )
-
- def _on_file_upload(self, change=None):
- """When file upload button is pressed."""
- filename, item = next(iter(change["new"].items()))
- content = item["content"]
-
- # Order matters make sure when pseudo change
- # the pseudo_filename is set
- with self.hold_trait_notifications():
- self.pseudo = UpfData(io.BytesIO(content), filename=filename)
- self.pseudo.store()
-
- # check if element is matched with the pseudo
- element = "".join([i for i in self.kind if not i.isdigit()])
- if element != self.pseudo.element:
- self.error_message = f""" ERROR: Element {self.kind} is not matched with the pseudo {self.pseudo.element}
"""
- self._reset()
- else:
- self.pseudo_text.value = filename
-
- def _reset(self):
- """Reset the widget to the initial state."""
- self.pseudo = None
- self.ecutrho = None
- self.ecutwfc = None
diff --git a/src/aiidalab_qe/app/configuration/workflow.py b/src/aiidalab_qe/app/configuration/workflow.py
deleted file mode 100644
index cba0df521..000000000
--- a/src/aiidalab_qe/app/configuration/workflow.py
+++ /dev/null
@@ -1,235 +0,0 @@
-"""Widgets for the submission of bands work chains.
-
-Authors: AiiDAlab team
-"""
-
-import ipywidgets as ipw
-import traitlets as tl
-
-from aiida import orm
-from aiida_quantumespresso.common.types import RelaxType
-from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS
-from aiidalab_qe.app.utils import get_entry_items
-from aiidalab_qe.common.panel import Panel
-
-
-class WorkChainSettings(Panel):
- identifier = "workchain"
-
- structure_title = ipw.HTML(
- """
-
Structure
"""
- )
- structure_help = ipw.HTML(
- """
- You have three options:
- (1) Structure as is: perform a self consistent calculation using the structure provided as input.
- (2) Atomic positions: perform a full relaxation of the internal atomic coordinates.
- (3) Full geometry: perform a full relaxation for both the internal atomic coordinates and the cell vectors.
"""
- )
- materials_help = ipw.HTML(
- """
- Below you can indicate both if the material should be treated as an insulator
- or a metal (if in doubt, choose "Metal"),
- and if it should be studied with magnetization/spin polarization,
- switch magnetism On or Off (On is at least twice more costly).
-
"""
- )
-
- properties_title = ipw.HTML(
- """
-
Properties
"""
- )
- protocol_title = ipw.HTML(
- """
-
Protocol
"""
- )
- protocol_help = ipw.HTML(
- """
- The "moderate" protocol represents a trade-off between
- accuracy and speed. Choose the "fast" protocol for a faster calculation
- with less precision and the "precise" protocol to aim at best accuracy (at the price of longer/costlier calculations).
"""
- )
-
- input_structure = tl.Instance(orm.StructureData, allow_none=True)
-
- def __init__(self, **kwargs):
- # RelaxType: degrees of freedom in geometry optimization
- self.relax_type = ipw.ToggleButtons(
- options=[
- ("Structure as is", "none"),
- ("Atomic positions", "positions"),
- ("Full geometry", "positions_cell"),
- ],
- value="positions_cell",
- )
-
- # SpinType: magnetic properties of material
- self.spin_type = ipw.ToggleButtons(
- options=[("Off", "none"), ("On", "collinear")],
- value=DEFAULT_PARAMETERS["workchain"]["spin_type"],
- style={"description_width": "initial"},
- )
-
- # ElectronicType: electronic properties of material
- self.electronic_type = ipw.ToggleButtons(
- options=[("Metal", "metal"), ("Insulator", "insulator")],
- value=DEFAULT_PARAMETERS["workchain"]["electronic_type"],
- style={"description_width": "initial"},
- )
-
- # Work chain protocol
- self.workchain_protocol = ipw.ToggleButtons(
- options=["fast", "moderate", "precise"],
- value="moderate",
- )
- self.properties = {}
- self.reminder_info = {}
- self.property_children = [
- self.properties_title,
- ipw.HTML("Select which properties to calculate:"),
- ]
- entries = get_entry_items("aiidalab_qe.properties", "outline")
- setting_entries = get_entry_items("aiidalab_qe.properties", "setting")
- for name, entry_point in entries.items():
- self.properties[name] = entry_point()
- self.reminder_info[name] = ipw.HTML()
- self.property_children.append(
- ipw.HBox([self.properties[name], self.reminder_info[name]])
- )
-
- # observer change to update the reminder text
- def update_reminder_info(change, name=name):
- if change["new"]:
- self.reminder_info[
- name
- ].value = (
- f"""Customize {name} settings in the panel above if needed."""
- )
- else:
- self.reminder_info[name].value = ""
-
- if name in setting_entries:
- self.properties[name].run.observe(update_reminder_info, "value")
-
- self.children = [
- self.structure_title,
- self.structure_help,
- self.relax_type,
- self.materials_help,
- ipw.HBox(
- children=[
- ipw.Label(
- "Electronic Type:",
- layout=ipw.Layout(justify_content="flex-start", width="120px"),
- ),
- self.electronic_type,
- ]
- ),
- ipw.HBox(
- children=[
- ipw.Label(
- "Magnetism:",
- layout=ipw.Layout(justify_content="flex-start", width="120px"),
- ),
- self.spin_type,
- ]
- ),
- *self.property_children,
- self.protocol_title,
- ipw.HTML("Select the protocol:", layout=ipw.Layout(flex="1 1 auto")),
- self.workchain_protocol,
- self.protocol_help,
- ]
- super().__init__(
- **kwargs,
- )
-
- @tl.observe("input_structure")
- def _on_input_structure_change(self, change):
- """Update the relax type options based on the input structure."""
- structure = change["new"]
- if structure is None or structure.pbc != (False, False, False):
- self.relax_type.options = [
- ("Structure as is", "none"),
- ("Atomic positions", "positions"),
- ("Full geometry", "positions_cell"),
- ]
- # Ensure the value is in the options
- if self.relax_type.value not in [
- option[1] for option in self.relax_type.options
- ]:
- self.relax_type.value = "positions_cell"
-
- self.properties["bands"].run.disabled = False
- elif structure.pbc == (False, False, False):
- self.relax_type.options = [
- ("Structure as is", "none"),
- ("Atomic positions", "positions"),
- ]
- # Ensure the value is in the options
- if self.relax_type.value not in [
- option[1] for option in self.relax_type.options
- ]:
- self.relax_type.value = "positions"
-
- self.properties["bands"].run.value = False
- self.properties["bands"].run.disabled = True
-
- def get_panel_value(self):
- # Work chain settings
- relax_type = self.relax_type.value
- electronic_type = self.electronic_type.value
- spin_type = self.spin_type.value
-
- protocol = self.workchain_protocol.value
-
- properties = []
-
- # add plugin specific settings
- run_bands = False
- run_pdos = False
- for name in self.properties:
- if self.properties[name].run.value:
- properties.append(name)
- if name == "bands":
- run_bands = True
- elif name == "pdos":
- run_bands = True
-
- if RelaxType(relax_type) is not RelaxType.NONE or not (run_bands or run_pdos):
- properties.append("relax")
- return {
- "protocol": protocol,
- "relax_type": relax_type,
- "properties": properties,
- "spin_type": spin_type,
- "electronic_type": electronic_type,
- }
-
- def set_panel_value(self, parameters):
- """Update the settings based on the given dict."""
- for key in [
- "relax_type",
- "spin_type",
- "electronic_type",
- ]:
- if key in parameters:
- getattr(self, key).value = parameters[key]
- if "protocol" in parameters:
- self.workchain_protocol.value = parameters["protocol"]
- properties = parameters.get("properties", [])
- for name in self.properties:
- if name in properties:
- self.properties[name].run.value = True
- else:
- self.properties[name].run.value = False
-
- def reset(self):
- """Reset the panel to the default value."""
- self.input_structure = None
- for key in ["relax_type", "spin_type", "electronic_type"]:
- getattr(self, key).value = DEFAULT_PARAMETERS["workchain"][key]
- self.workchain_protocol.value = DEFAULT_PARAMETERS["workchain"]["protocol"]
- for key, p in self.properties.items():
- p.run.value = key in DEFAULT_PARAMETERS["workchain"]["properties"]
diff --git a/src/aiidalab_qe/app/main.py b/src/aiidalab_qe/app/main.py
index 471cba3f0..4dbd02f9e 100644
--- a/src/aiidalab_qe/app/main.py
+++ b/src/aiidalab_qe/app/main.py
@@ -8,11 +8,17 @@
from IPython.display import Javascript, display
from aiida.orm import load_node
+from aiida.orm.utils.serialize import deserialize_unsafe
from aiidalab_qe.app.configuration import ConfigureQeAppWorkChainStep
+from aiidalab_qe.app.configuration.model import ConfigurationStepModel
from aiidalab_qe.app.result import ViewQeAppWorkChainStatusAndResultsStep
+from aiidalab_qe.app.result.model import ResultsStepModel
from aiidalab_qe.app.structure import StructureSelectionStep
+from aiidalab_qe.app.structure.model import StructureStepModel
from aiidalab_qe.app.submission import SubmitQeAppWorkChainStep
-from aiidalab_widgets_base import WizardAppWidget, WizardAppWidgetStep
+from aiidalab_qe.app.submission.model import SubmissionStepModel
+from aiidalab_qe.common.widgets import LoadingWidget
+from aiidalab_widgets_base import WizardAppWidget
class App(ipw.VBox):
@@ -22,41 +28,50 @@ class App(ipw.VBox):
process = tl.Union([tl.Unicode(), tl.Int()], allow_none=True)
def __init__(self, qe_auto_setup=True):
+ # Initialize the models
+ self.structure_model = StructureStepModel()
+ self.configure_model = ConfigurationStepModel()
+ self.submit_model = SubmissionStepModel()
+ self.results_model = ResultsStepModel()
+
# Create the application steps
- self.structure_step = StructureSelectionStep(auto_advance=True)
- self.structure_step.observe(self._observe_structure_selection, "structure")
- self.configure_step = ConfigureQeAppWorkChainStep(auto_advance=True)
+ self.structure_step = StructureSelectionStep(
+ model=self.structure_model,
+ auto_advance=True,
+ )
+ self.configure_step = ConfigureQeAppWorkChainStep(
+ model=self.configure_model,
+ auto_advance=True,
+ )
self.submit_step = SubmitQeAppWorkChainStep(
+ model=self.submit_model,
auto_advance=True,
qe_auto_setup=qe_auto_setup,
)
- self.results_step = ViewQeAppWorkChainStatusAndResultsStep()
+ self.results_step = ViewQeAppWorkChainStatusAndResultsStep(
+ model=self.results_model,
+ )
- # Link the application steps
+ # Wizard step observations
ipw.dlink(
(self.structure_step, "state"),
(self.configure_step, "previous_step_state"),
)
- ipw.dlink(
- (self.structure_step, "confirmed_structure"),
- (self.submit_step, "input_structure"),
- )
- ipw.dlink(
- (self.structure_step, "confirmed_structure"),
- (self.configure_step, "input_structure"),
+ self.structure_model.observe(
+ self._on_structure_confirmation_change,
+ "confirmed",
)
ipw.dlink(
(self.configure_step, "state"),
(self.submit_step, "previous_step_state"),
)
- ipw.dlink(
- (self.configure_step, "configuration_parameters"),
- (self.submit_step, "input_parameters"),
+ self.configure_model.observe(
+ self._on_configuration_confirmation_change,
+ "confirmed",
)
- ipw.dlink(
- (self.submit_step, "process"),
- (self.results_step, "process"),
- transform=lambda node: node.uuid if node is not None else None,
+ self.submit_model.observe(
+ self._on_submission,
+ "confirmed",
)
# Add the application steps to the application
@@ -68,93 +83,119 @@ def __init__(self, qe_auto_setup=True):
("Status & Results", self.results_step),
]
)
- # hide the header
- self._wizard_app_widget.children[0].layout.display = "none"
- self._wizard_app_widget.observe(self._observe_selected_index, "selected_index")
+ self._wizard_app_widget.observe(
+ self._on_step_change,
+ "selected_index",
+ )
+
+ # Hide the header
+ self._wizard_app_widget.children[0].layout.display = "none" # type: ignore
# Add a button to start a new calculation
- self.new_work_chains_button = ipw.Button(
- description="Start New Calculation",
- tooltip="Open a new page to start a separate calculation",
+ self.new_workchain_button = ipw.Button(
+ layout=ipw.Layout(width="auto"),
button_style="success",
icon="plus-circle",
- layout=ipw.Layout(width="30%"),
+ description="Start New Calculation",
+ tooltip="Open a new page to start a separate calculation",
)
- def on_button_click(_):
- display(Javascript("window.open('./qe.ipynb', '_blank')"))
+ self.new_workchain_button.on_click(self._on_new_workchain_button_click)
- self.new_work_chains_button.on_click(on_button_click)
+ self._process_loading_message = LoadingWidget(
+ message="Loading process",
+ layout=ipw.Layout(display="none"),
+ )
super().__init__(
children=[
- self.new_work_chains_button,
+ self.new_workchain_button,
+ self._process_loading_message,
self._wizard_app_widget,
]
)
+ self._wizard_app_widget.selected_index = None
+
+ self._update_blockers()
+
@property
def steps(self):
return self._wizard_app_widget.steps
- # Reset the confirmed_structure in case that a new structure is selected
- def _observe_structure_selection(self, change):
- with self.structure_step.hold_sync():
- if (
- self.structure_step.confirmed_structure is not None
- and self.structure_step.confirmed_structure != change["new"]
- ):
- self.structure_step.confirmed_structure = None
-
- def _observe_selected_index(self, change):
- """Check unsaved change in the step when leaving the step."""
- # no accordion tab is selected
- if not change["new"]:
- return
- new_idx = change["new"]
- # only when entering the submit step, check and udpate the blocker messages
- # steps[new_idx][0] is the title of the step
- if self.steps[new_idx][1] is not self.submit_step:
- return
- blockers = []
- # Loop over all steps before the submit step
- for title, step in self.steps[:new_idx]:
- # check if the step is saved
- if not step.is_saved():
- step.state = WizardAppWidgetStep.State.CONFIGURED
- blockers.append(
- f"Unsaved changes in the {title} step. Please save the changes before submitting."
- )
- self.submit_step.external_submission_blockers = blockers
-
@tl.observe("process")
- def _observe_process(self, change):
- from aiida.orm.utils.serialize import deserialize_unsafe
+ def _on_process_change(self, change):
+ self._update_from_process(change["new"])
+
+ def _on_new_workchain_button_click(self, _):
+ display(Javascript("window.open('./qe.ipynb', '_blank')"))
+
+ def _on_step_change(self, change):
+ if (step_index := change["new"]) is not None:
+ self._render_step(step_index)
+
+ def _on_structure_confirmation_change(self, _):
+ self._update_configuration_step()
+ self._update_blockers()
+
+ def _on_configuration_confirmation_change(self, _):
+ self._update_submission_step()
+ self._update_blockers()
+
+ def _on_submission(self, _):
+ self._update_results_step()
+
+ def _render_step(self, step_index):
+ step = self.steps[step_index][1]
+ step.render()
+
+ def _update_configuration_step(self):
+ if self.structure_model.confirmed:
+ self.configure_model.input_structure = self.structure_model.input_structure
+ else:
+ self.configure_model.input_structure = None
+
+ def _update_submission_step(self):
+ if self.configure_model.confirmed:
+ self.submit_model.input_structure = self.structure_model.input_structure
+ self.submit_model.input_parameters = self.configure_model.get_model_state()
+ else:
+ self.submit_model.input_structure = None
+ self.submit_model.input_parameters = {}
+
+ def _update_results_step(self):
+ node = self.submit_model.process_node
+ self.results_model.process_uuid = node.uuid if node is not None else None
+
+ def _update_blockers(self):
+ self.submit_model.external_submission_blockers = [
+ f"Unsaved changes in the {title} step. Please confirm the changes before submitting."
+ for title, step in self.steps[:2]
+ if not step.is_saved()
+ ]
- if change["old"] == change["new"]:
- return
- pk = change["new"]
+ def _update_from_process(self, pk):
if pk is None:
self._wizard_app_widget.reset()
self._wizard_app_widget.selected_index = 0
else:
- process = load_node(pk)
- with self.structure_step.manager.hold_sync():
- with self.structure_step.hold_sync():
- self._wizard_app_widget.selected_index = 3
- self.structure_step.manager.viewer.structure = (
- process.inputs.structure.get_ase()
- )
- self.structure_step.structure = process.inputs.structure
- self.structure_step.confirm()
- self.submit_step.process = process
-
- # set ui_parameters
- # print out error message if yaml format ui_parameters is not reachable
- ui_parameters = process.base.extras.get("ui_parameters", {})
- if ui_parameters and isinstance(ui_parameters, str):
- ui_parameters = deserialize_unsafe(ui_parameters)
- self.configure_step.set_configuration_parameters(ui_parameters)
- self.configure_step.confirm()
- self.submit_step.set_submission_parameters(ui_parameters)
- self.submit_step.state = self.submit_step.State.SUCCESS
+ self._show_process_loading_message()
+ process_node = load_node(pk)
+ self.structure_model.input_structure = process_node.inputs.structure
+ self.structure_model.confirm()
+ parameters = process_node.base.extras.get("ui_parameters", {})
+ if parameters and isinstance(parameters, str):
+ parameters = deserialize_unsafe(parameters)
+ self.configure_model.set_model_state(parameters)
+ self.configure_model.confirm()
+ self.submit_model.process_node = process_node
+ self.submit_model.set_model_state(parameters)
+ self.submit_model.confirm()
+ self._wizard_app_widget.selected_index = 3
+ self._hide_process_loading_message()
+
+ def _show_process_loading_message(self):
+ self._process_loading_message.layout.display = "flex"
+
+ def _hide_process_loading_message(self):
+ self._process_loading_message.layout.display = "none"
diff --git a/src/aiidalab_qe/app/result/__init__.py b/src/aiidalab_qe/app/result/__init__.py
index 3fc6e36ba..faa9ec8f1 100644
--- a/src/aiidalab_qe/app/result/__init__.py
+++ b/src/aiidalab_qe/app/result/__init__.py
@@ -3,15 +3,16 @@
from aiida import orm
from aiida.engine import ProcessState
-from aiida.engine.processes import control
+from aiidalab_qe.common.widgets import LoadingWidget
from aiidalab_widgets_base import (
ProcessMonitor,
ProcessNodesTreeWidget,
WizardAppWidgetStep,
)
+from aiidalab_widgets_base.viewers import viewer as node_viewer
-# trigger registration of the viewer widget:
-from .workchain_viewer import WorkChainViewer # noqa: F401
+from .model import ResultsStepModel
+from .viewer import WorkChainViewer, WorkChainViewerModel
PROCESS_COMPLETED = "Workflow completed successfully!
"
PROCESS_EXCEPTED = "Workflow is excepted!
"
@@ -19,210 +20,242 @@
class ViewQeAppWorkChainStatusAndResultsStep(ipw.VBox, WizardAppWidgetStep):
- process = tl.Unicode(allow_none=True)
-
- def __init__(self, **kwargs):
- self.process_tree = ProcessNodesTreeWidget()
- ipw.dlink(
- (self, "process"),
- (self.process_tree, "value"),
+ def __init__(self, model: ResultsStepModel, **kwargs):
+ super().__init__(
+ children=[LoadingWidget("Loading results step")],
+ **kwargs,
)
- self.process_tree.observe(self._update_node_view, names="selected_nodes")
- # keep track of the node views
- self.node_views = {}
- self.process_status = ipw.VBox(children=[self.process_tree])
- # Setup process monitor
- self.process_monitor = ProcessMonitor(
- timeout=0.2,
- callbacks=[
- self.process_tree.update,
- self._update_state,
- ],
+ self._model = model
+ self._model.observe(
+ self._on_process_change,
+ "process_uuid",
)
- ipw.dlink((self, "process"), (self.process_monitor, "value"))
+
+ self.rendered = False
+
+ self.node_views = {} # node-view cache
+
+ self.node_view_loading_message = LoadingWidget("Loading node view")
+
+ def render(self):
+ if self.rendered:
+ return
self.kill_button = ipw.Button(
description="Kill workchain",
tooltip="Kill the below workchain.",
button_style="danger",
icon="stop",
- layout=ipw.Layout(width="120px", display="none", margin="0px 20px 0px 0px"),
+ layout=ipw.Layout(width="auto", display="none"),
+ )
+ ipw.dlink(
+ (self, "state"),
+ (self.kill_button, "disabled"),
+ lambda state: state is not self.State.ACTIVE,
+ )
+ self.kill_button.on_click(self._on_kill_button_click)
+
+ self.update_results_button = ipw.Button(
+ description="Update results",
+ tooltip="Trigger the update of the results.",
+ button_style="success",
+ icon="refresh",
+ layout=ipw.Layout(width="auto", display="block"),
)
- self.kill_button.on_click(self._on_click_kill_button)
+ self.update_results_button.on_click(self._on_update_results_button_click)
self.clean_scratch_button = ipw.Button(
description="Clean remote data",
tooltip="Clean the remote folders of the workchain.",
button_style="danger",
icon="trash",
- layout=ipw.Layout(width="150px", display="none", margin="0px 20px 0px 0px"),
+ layout=ipw.Layout(width="auto", display="none"),
)
- self.clean_scratch_button.on_click(self._on_click_clean_scratch_button)
- self.update_result_button = ipw.Button(
- description="Update results tabs",
- tooltip="Trigger the update of the results tabs.",
- button_style="success",
- icon="refresh",
- layout=ipw.Layout(
- width="150px", display="block", margin="0px 20px 0px 0px"
- ),
+ ipw.dlink(
+ (self._model, "process_remote_folder_is_clean"),
+ (self.clean_scratch_button, "disabled"),
)
- self.update_result_button.on_click(self._on_click_update_result_button)
+ self.clean_scratch_button.on_click(self._on_clean_scratch_button_click)
self.process_info = ipw.HTML()
+ ipw.dlink(
+ (self._model, "process_info"),
+ (self.process_info, "value"),
+ )
- super().__init__(
- [
- self.process_info,
- ipw.HBox(
- children=[
- self.kill_button,
- self.update_result_button,
- self.clean_scratch_button,
- ]
- ),
- self.process_status,
+ self.process_tree = ProcessNodesTreeWidget()
+ self.process_tree.observe(
+ self._on_node_selection_change,
+ "selected_nodes",
+ )
+ ipw.dlink(
+ (self._model, "process_uuid"),
+ (self.process_tree, "value"),
+ )
+
+ self.node_view_container = ipw.VBox()
+
+ self.process_monitor = ProcessMonitor(
+ timeout=0.2,
+ callbacks=[
+ self.process_tree.update,
+ self._update_status,
+ self._update_state,
],
- **kwargs,
)
+ self.children = [
+ self.process_info,
+ ipw.HBox(
+ children=[
+ self.kill_button,
+ self.update_results_button,
+ self.clean_scratch_button,
+ ],
+ layout=ipw.Layout(margin="0 3px"),
+ ),
+ self.process_tree,
+ self.node_view_container,
+ ]
+
+ self.rendered = True
+
self._update_kill_button_layout()
+ self._update_clean_scratch_button_layout()
+
+ # This triggers the start of the monitor on a separate threadF
+ ipw.dlink(
+ (self._model, "process_uuid"),
+ (self.process_monitor, "value"),
+ )
def can_reset(self):
- "Do not allow reset while process is running."
+ "Checks if process is running (active), which disallows a reset."
return self.state is not self.State.ACTIVE
def reset(self):
- self.process = None
+ self._model.reset()
- def _update_state(self):
- """Based on the process state, update the state of the step."""
- if self.process is None:
- self.state = self.State.INIT
- else:
- process = orm.load_node(self.process)
- process_state = process.process_state
- if process_state in (
- ProcessState.CREATED,
- ProcessState.RUNNING,
- ProcessState.WAITING,
- ):
- self.state = self.State.ACTIVE
- self.process_info.value = PROCESS_RUNNING
- elif (
- process_state in (ProcessState.EXCEPTED, ProcessState.KILLED)
- or process.is_failed
- ):
- self.state = self.State.FAIL
- self.process_info.value = PROCESS_EXCEPTED
- elif process.is_finished_ok:
- self.state = self.State.SUCCESS
- self.process_info.value = PROCESS_COMPLETED
- # trigger the update of kill and clean button.
- if self.state in [self.State.SUCCESS, self.State.FAIL]:
- self._update_kill_button_layout()
- self._update_clean_scratch_button_layout()
+ @tl.observe("state")
+ def _on_state_change(self, _):
+ self._update_kill_button_layout()
- def _update_kill_button_layout(self):
- """Update the layout of the kill button."""
- # If no process is selected, hide the button.
- if self.process is None or self.process == "":
- self.kill_button.layout.display = "none"
- else:
- process = orm.load_node(self.process)
- # If the process is terminated, hide the button.
- if process.is_terminated:
- self.kill_button.layout.display = "none"
- else:
- self.kill_button.layout.display = "block"
-
- # If the step is not activated, no point to click the button, so disable it.
- # Only enable it if the process is on (RUNNING, CREATED, WAITING).
- if self.state is self.State.ACTIVE:
- self.kill_button.disabled = False
- else:
- self.kill_button.disabled = True
+ def _on_process_change(self, _):
+ self._model.update()
+ self._update_state()
+ self._update_kill_button_layout()
+ self._update_clean_scratch_button_layout()
- def _update_clean_scratch_button_layout(self):
- """Update the layout of the kill button."""
- # The button is hidden by default, but if we load a new process, we hide again.
- if not self.process:
- self.clean_scratch_button.layout.display = "none"
- else:
- process = orm.load_node(self.process)
- # If the process is terminated, show the button.
- if process.is_terminated:
- self.clean_scratch_button.layout.display = "block"
- else:
- self.clean_scratch_button.layout.display = "none"
-
- # If the scratch is already empty, we should deactivate the button.
- # not sure about the performance if descendants are several.
- cleaned_bool = []
- for called_descendant in process.called_descendants:
- if isinstance(called_descendant, orm.CalcJobNode):
- try:
- cleaned_bool.append(
- called_descendant.outputs.remote_folder.is_empty
- )
- except Exception:
- pass
- self.clean_scratch_button.disabled = all(cleaned_bool)
-
- def _on_click_kill_button(self, _=None):
- """callback for the kill button.
- First kill the process, then update the kill button layout.
- """
- workchain = [orm.load_node(self.process)]
- control.kill_processes(workchain)
+ def _on_node_selection_change(self, change):
+ self._update_node_view(change["new"])
- # update the kill button layout
+ def _on_kill_button_click(self, _):
+ self._model.kill_process()
self._update_kill_button_layout()
- def _on_click_clean_scratch_button(self, _=None):
- """callback for the clean scratch button.
- First clean the remote folders, then update the clean button layout.
- """
- process = orm.load_node(self.process)
+ def _on_update_results_button_click(self, _):
+ self._update_node_view(self.process_tree.selected_nodes, refresh=True)
- for called_descendant in process.called_descendants:
- if isinstance(called_descendant, orm.CalcJobNode):
- try:
- called_descendant.outputs.remote_folder._clean()
- except Exception:
- pass
-
- # update the kill button layout
+ def _on_clean_scratch_button_click(self, _):
+ self._model.clean_remote_data()
self._update_clean_scratch_button_layout()
- def _on_click_update_result_button(self, _=None):
- """Trigger the update of the results tabs."""
- # change the node to trigger the update of the view.
- self._update_node_view({"new": self.process_tree.selected_nodes}, refresh=True)
+ def _update_node_view(self, nodes, refresh=False):
+ """Update the node view based on the selected nodes.
- @tl.observe("process")
- def _observe_process(self, _):
- """Callback for when the process is changed."""
- # The order of the following calls matters,
- # as the self.state is updated in the _update_state method.
- self._update_state()
- self._update_kill_button_layout()
- self._update_clean_scratch_button_layout()
-
- def _update_node_view(self, change, refresh=False):
- """Callback for when the a new node is selected."""
- from aiidalab_widgets_base.viewers import viewer
+ parameters
+ ----------
+ `nodes`: `list`
+ List of selected nodes.
+ `refresh`: `bool`, optional
+ If True, the viewer will be refreshed.
+ Occurs when user presses the "Update results" button.
+ """
- nodes = change["new"]
if not nodes:
return
# only show the first selected node
node = nodes[0]
+
# check if the viewer is already added
if node.uuid in self.node_views and not refresh:
- node_view = self.node_views[node.uuid]
+ self.node_view = self.node_views[node.uuid]
+ elif not isinstance(node, orm.WorkChainNode):
+ self.node_view_container.children = [self.node_view_loading_message]
+ self.node_view = node_viewer(node)
+ self.node_views[node.uuid] = self.node_view
+ elif node.process_label == "QeAppWorkChain":
+ self.node_view_container.children = [self.node_view_loading_message]
+ self.node_view = self._create_workchain_viewer(node)
+ self.node_views[node.uuid] = self.node_view
+
+ self.node_view_container.children = [self.node_view]
+
+ def _create_workchain_viewer(self, node: orm.WorkChainNode):
+ model = WorkChainViewerModel()
+ ipw.dlink(
+ (self._model, "monitor_counter"),
+ (model, "monitor_counter"),
+ )
+ node_view: WorkChainViewer = node_viewer(node, model=model) # type: ignore
+ node_view.render()
+ return node_view
+
+ def _update_kill_button_layout(self):
+ if not self.rendered:
+ return
+ process_node = self._model.fetch_process_node()
+ if (
+ not process_node
+ or process_node.is_finished
+ or process_node.is_excepted
+ or self.state
+ in (
+ self.State.SUCCESS,
+ self.State.FAIL,
+ )
+ ):
+ self.kill_button.layout.display = "none"
+ else:
+ self.kill_button.layout.display = "block"
+
+ def _update_clean_scratch_button_layout(self):
+ if not self.rendered:
+ return
+ process_node = self._model.fetch_process_node()
+ if process_node and process_node.is_terminated:
+ self.clean_scratch_button.layout.display = "block"
else:
- node_view = viewer(node)
- self.node_views[node.uuid] = node_view
- self.process_status.children = [self.process_tree, node_view]
+ self.clean_scratch_button.layout.display = "none"
+
+ def _update_status(self):
+ self._model.monitor_counter += 1
+
+ def _update_state(self):
+ process_node = self._model.fetch_process_node()
+ if not process_node:
+ self.state = self.State.INIT
+ elif process_node.process_state in (
+ ProcessState.CREATED,
+ ProcessState.RUNNING,
+ ProcessState.WAITING,
+ ):
+ self.state = self.State.ACTIVE
+ self._model.process_info = PROCESS_RUNNING
+ elif (
+ process_node.process_state
+ in (
+ ProcessState.EXCEPTED,
+ ProcessState.KILLED,
+ )
+ or process_node.is_failed
+ ):
+ self.state = self.State.FAIL
+ self._model.process_info = PROCESS_EXCEPTED
+ elif process_node.is_finished_ok:
+ self.state = self.State.SUCCESS
+ self._model.process_info = PROCESS_COMPLETED
+ if self.state in (self.State.SUCCESS, self.State.FAIL):
+ self._update_kill_button_layout()
+ self._update_clean_scratch_button_layout()
diff --git a/src/aiidalab_qe/app/result/model.py b/src/aiidalab_qe/app/result/model.py
new file mode 100644
index 000000000..71b62da2f
--- /dev/null
+++ b/src/aiidalab_qe/app/result/model.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+import contextlib
+
+import traitlets as tl
+
+from aiida import orm
+from aiida.engine.processes import control
+from aiidalab_qe.common.mixins import HasProcess
+from aiidalab_qe.common.mvc import Model
+
+
+class ResultsStepModel(Model, HasProcess):
+ process_info = tl.Unicode("")
+ process_remote_folder_is_clean = tl.Bool(False)
+
+ def update(self):
+ self._update_process_remote_folder_state()
+
+ def kill_process(self):
+ if process_node := self.fetch_process_node():
+ control.kill_processes([process_node])
+
+ def clean_remote_data(self):
+ if not (process_node := self.fetch_process_node()):
+ return
+ for called_descendant in process_node.called_descendants:
+ if isinstance(called_descendant, orm.CalcJobNode):
+ with contextlib.suppress(Exception):
+ called_descendant.outputs.remote_folder._clean()
+ self.process_remote_folder_is_clean = True
+
+ def reset(self):
+ self.process_uuid = None
+ self.process_info = ""
+
+ def _update_process_remote_folder_state(self):
+ if not (process_node := self.fetch_process_node()):
+ return
+ cleaned = []
+ for called_descendant in process_node.called_descendants:
+ if isinstance(called_descendant, orm.CalcJobNode):
+ with contextlib.suppress(Exception):
+ cleaned.append(called_descendant.outputs.remote_folder.is_empty)
+ self.process_remote_folder_is_clean = all(cleaned)
diff --git a/src/aiidalab_qe/app/result/structure/__init__.py b/src/aiidalab_qe/app/result/structure/__init__.py
new file mode 100644
index 000000000..35c30d920
--- /dev/null
+++ b/src/aiidalab_qe/app/result/structure/__init__.py
@@ -0,0 +1,7 @@
+from .model import StructureResultsModel
+from .structure import StructureResults
+
+__all__ = [
+ "StructureResultsModel",
+ "StructureResults",
+]
diff --git a/src/aiidalab_qe/app/result/structure/model.py b/src/aiidalab_qe/app/result/structure/model.py
new file mode 100644
index 000000000..6ffb47ea8
--- /dev/null
+++ b/src/aiidalab_qe/app/result/structure/model.py
@@ -0,0 +1,11 @@
+from aiidalab_qe.common.panel import ResultsModel
+
+
+class StructureResultsModel(ResultsModel):
+ identifier = "structure"
+
+ _this_process_label = "PwRelaxWorkChain"
+
+ @property
+ def include(self):
+ return "relax" in self.properties
diff --git a/src/aiidalab_qe/app/result/structure/structure.py b/src/aiidalab_qe/app/result/structure/structure.py
new file mode 100644
index 000000000..fe2dc3d60
--- /dev/null
+++ b/src/aiidalab_qe/app/result/structure/structure.py
@@ -0,0 +1,16 @@
+from aiidalab_qe.common.panel import ResultsPanel
+from aiidalab_widgets_base.viewers import StructureDataViewer
+
+from .model import StructureResultsModel
+
+
+class StructureResults(ResultsPanel[StructureResultsModel]):
+ title = "Final Geometry"
+ identifier = "structure"
+
+ def render(self):
+ if self.rendered:
+ return
+ widget = StructureDataViewer(structure=self._model.outputs.structure)
+ self.children = [widget]
+ self.rendered = True
diff --git a/src/aiidalab_qe/app/result/summary/__init__.py b/src/aiidalab_qe/app/result/summary/__init__.py
new file mode 100644
index 000000000..b57eab187
--- /dev/null
+++ b/src/aiidalab_qe/app/result/summary/__init__.py
@@ -0,0 +1,7 @@
+from .model import WorkChainSummaryModel
+from .summary import WorkChainSummary
+
+__all__ = [
+ "WorkChainSummaryModel",
+ "WorkChainSummary",
+]
diff --git a/src/aiidalab_qe/app/result/summary/model.py b/src/aiidalab_qe/app/result/summary/model.py
new file mode 100644
index 000000000..d44f027b4
--- /dev/null
+++ b/src/aiidalab_qe/app/result/summary/model.py
@@ -0,0 +1,232 @@
+from aiida_quantumespresso.workflows.pw.bands import PwBandsWorkChain
+from aiidalab_qe.common.panel import ResultsModel
+
+FUNCTIONAL_LINK_MAP = {
+ "PBE": "https://journals.aps.org/prl/abstract/10.1103/PhysRevLett.77.3865",
+ "PBEsol": "https://journals.aps.org/prl/abstract/10.1103/PhysRevLett.100.136406",
+}
+
+PSEUDO_LINK_MAP = {
+ "SSSP": "https://www.materialscloud.org/discover/sssp/table/efficiency",
+ "PseudoDojo": "http://www.pseudo-dojo.org/",
+}
+
+FUNCTIONAL_REPORT_MAP = {
+ "LDA": "local density approximation (LDA)",
+ "PBE": "generalized gradient approximation of Perdew-Burke-Ernzerhof (PBE)",
+ "PBEsol": "the revised generalized gradient approximation of Perdew-Burke-Ernzerhof (PBE) for solids",
+}
+
+# Periodicity
+PERIODICITY_MAPPING = {
+ (True, True, True): "xyz",
+ (True, True, False): "xy",
+ (True, False, False): "x",
+ (False, False, False): "molecule",
+}
+
+VDW_CORRECTION_VERSION = {
+ 3: "Grimme-D3",
+ 4: "Grimme-D3BJ",
+ 5: "Grimme-D3M",
+ 6: "Grimme-D3MBJ",
+ "ts-vdw": "Tkatchenko-Scheffler",
+ "none": "None",
+}
+
+
+class WorkChainSummaryModel(ResultsModel):
+ identifier = "summary"
+
+ @property
+ def include(self):
+ return True
+
+ def generate_report_html(self):
+ """Read from the bulider parameters and generate a html for reporting
+ the inputs for the `QeAppWorkChain`.
+ """
+ from importlib.resources import files
+
+ from jinja2 import Environment
+
+ from aiidalab_qe.app.static import styles, templates
+
+ def _fmt_yes_no(truthy):
+ return "Yes" if truthy else "No"
+
+ env = Environment()
+ env.filters.update(
+ {
+ "fmt_yes_no": _fmt_yes_no,
+ }
+ )
+ template = files(templates).joinpath("workflow_summary.jinja").read_text()
+ style = files(styles).joinpath("style.css").read_text()
+ parameters = self._generate_report_parameters()
+ report = {key: value for key, value in parameters.items() if value is not None}
+
+ return env.from_string(template).render(style=style, **report)
+
+ def generate_report_text(self, report_dict):
+ """Generate a text for reporting the inputs for the `QeAppWorkChain`
+
+ :param report_dict: dictionary generated by the `generate_report_dict` function.
+ """
+
+ report_string = (
+ "All calculations are performed within the density-functional "
+ "theory formalism as implemented in the Quantum ESPRESSO code. "
+ "The pseudopotential for each element is extracted from the "
+ f'{report_dict["Pseudopotential library"][0]} '
+ "library. The wave functions "
+ "of the valence electrons are expanded in a plane wave basis set, using an "
+ "energy cutoff equal to "
+ f'{round(report_dict["Plane wave energy cutoff (wave functions)"][0])} Ry '
+ "for the wave functions and "
+ f'{round(report_dict["Plane wave energy cutoff (charge density)"][0])} Ry '
+ "for the charge density and potential. "
+ "The exchange-correlation energy is "
+ "calculated using the "
+ f'{FUNCTIONAL_REPORT_MAP[report_dict["Functional"][0]]}. '
+ "A Monkhorst-Pack mesh is used for sampling the Brillouin zone, where the "
+ "distance between the k-points is set to "
+ )
+ kpoints_distances = []
+ kpoints_calculations = []
+
+ for calc in ("SCF", "NSCF", "Bands"):
+ if f"K-point mesh distance ({calc})" in report_dict:
+ kpoints_distances.append(
+ str(report_dict[f"K-point mesh distance ({calc})"][0])
+ )
+ kpoints_calculations.append(calc)
+
+ report_string += ", ".join(kpoints_distances)
+ report_string += " for the "
+ report_string += ", ".join(kpoints_calculations)
+ report_string += " calculation"
+ if len(kpoints_distances) > 1:
+ report_string += "s, respectively"
+ report_string += "."
+
+ return report_string
+
+ def _generate_report_parameters(self):
+ """Generate the report parameters from the ui parameters and workchain's input.
+
+ Parameters extracted from ui parameters, directly from the widgets,
+ such as the ``pseudo_family`` and ``relax_type``.
+
+ Parameters extracted from workchain's inputs, such as the ``energy_cutoff_wfc``
+ and ``energy_cutoff_rho``.
+
+ Return a dictionary of the parameters.
+ """
+ from aiida.orm.utils.serialize import deserialize_unsafe
+
+ if not (qeapp_wc := self.fetch_process_node()):
+ return {"error": "WorkChain not found."}
+
+ ui_parameters = qeapp_wc.base.extras.get("ui_parameters", {})
+ if isinstance(ui_parameters, str):
+ ui_parameters = deserialize_unsafe(ui_parameters)
+ # Construct the report parameters needed for the report
+ # drop support for old ui parameters
+ if "workchain" not in ui_parameters:
+ return {}
+ report = {
+ "relaxed": None
+ if ui_parameters["workchain"]["relax_type"] == "none"
+ else ui_parameters["workchain"]["relax_type"],
+ "relax_method": ui_parameters["workchain"]["relax_type"],
+ "electronic_type": ui_parameters["workchain"]["electronic_type"],
+ "material_magnetic": ui_parameters["workchain"]["spin_type"],
+ "protocol": ui_parameters["workchain"]["protocol"],
+ "initial_magnetic_moments": ui_parameters["advanced"][
+ "initial_magnetic_moments"
+ ],
+ "properties": ui_parameters["workchain"]["properties"],
+ }
+ #
+ report.update(
+ {
+ "bands_computed": "bands" in ui_parameters["workchain"]["properties"],
+ "pdos_computed": "pdos" in ui_parameters["workchain"]["properties"],
+ }
+ )
+ # update pseudo family information to report
+ pseudo_family = ui_parameters["advanced"].get("pseudo_family")
+ pseudo_family_info = pseudo_family.split("/")
+ pseudo_library = pseudo_family_info[0]
+ functional = pseudo_family_info[2]
+ if pseudo_library == "SSSP":
+ pseudo_protocol = pseudo_family_info[3]
+ elif pseudo_library == "PseudoDojo":
+ pseudo_protocol = pseudo_family_info[4]
+ report.update(
+ {
+ "pseudo_family": pseudo_family,
+ "pseudo_library": pseudo_library,
+ "pseudo_version": pseudo_family_info[1],
+ "functional": functional,
+ "pseudo_protocol": pseudo_protocol,
+ "pseudo_link": PSEUDO_LINK_MAP[pseudo_library],
+ "functional_link": FUNCTIONAL_LINK_MAP[functional],
+ }
+ )
+ # Extract the pw calculation parameters from the workchain's inputs
+ # energy_cutoff is same for all pw calculations when pseudopotentials are fixed
+ # as well as the smearing settings (semaring and degauss) and scf kpoints distance
+ # read from the first pw calculation of relax workflow.
+ # It is safe then to extract these parameters from the first pw calculation, since the
+ # builder is anyway set with subworkchain inputs even it is not run which controlled by
+ # the properties inputs.
+ pw_parameters = qeapp_wc.inputs.relax.base.pw.parameters.get_dict()
+ energy_cutoff_wfc = pw_parameters["SYSTEM"]["ecutwfc"]
+ energy_cutoff_rho = pw_parameters["SYSTEM"]["ecutrho"]
+ occupation = pw_parameters["SYSTEM"]["occupations"]
+ scf_kpoints_distance = (
+ qeapp_wc.inputs.relax.base.kpoints_distance.base.attributes.get("value")
+ )
+ report.update(
+ {
+ "energy_cutoff_wfc": energy_cutoff_wfc,
+ "energy_cutoff_rho": energy_cutoff_rho,
+ "occupation_type": occupation,
+ "scf_kpoints_distance": scf_kpoints_distance,
+ }
+ )
+ if occupation == "smearing":
+ report["degauss"] = pw_parameters["SYSTEM"]["degauss"]
+ report["smearing"] = pw_parameters["SYSTEM"]["smearing"]
+ report["tot_charge"] = pw_parameters["SYSTEM"].get("tot_charge", 0.0)
+ report["vdw_corr"] = VDW_CORRECTION_VERSION.get(
+ pw_parameters["SYSTEM"].get("dftd3_version"),
+ pw_parameters["SYSTEM"].get("vdw_corr", "none"),
+ )
+ report["periodicity"] = PERIODICITY_MAPPING.get(
+ qeapp_wc.inputs.structure.pbc, "xyz"
+ )
+
+ # Spin-Oribit coupling
+ report["spin_orbit"] = pw_parameters["SYSTEM"].get("lspinorb", False)
+
+ if hubbard_dict := ui_parameters["advanced"].pop("hubbard_parameters", None):
+ hubbard_parameters = hubbard_dict["hubbard_u"]
+ report["hubbard_u"] = hubbard_parameters
+ report["tot_magnetization"] = pw_parameters["SYSTEM"].get(
+ "tot_magnetization", False
+ )
+
+ # hard code bands and pdos
+ if "bands" in qeapp_wc.inputs:
+ report["bands_kpoints_distance"] = PwBandsWorkChain.get_protocol_inputs(
+ report["protocol"]
+ )["bands_kpoints_distance"]
+
+ if "pdos" in qeapp_wc.inputs:
+ report["nscf_kpoints_distance"] = (
+ qeapp_wc.inputs.pdos.nscf.kpoints_distance.base.attributes.get("value")
+ )
+ return report
diff --git a/src/aiidalab_qe/app/result/summary/summary.py b/src/aiidalab_qe/app/result/summary/summary.py
new file mode 100644
index 000000000..8ee839ac8
--- /dev/null
+++ b/src/aiidalab_qe/app/result/summary/summary.py
@@ -0,0 +1,17 @@
+import ipywidgets as ipw
+
+from aiidalab_qe.common.panel import ResultsPanel
+
+from .model import WorkChainSummaryModel
+
+
+class WorkChainSummary(ResultsPanel[WorkChainSummaryModel]):
+ title = "Workflow Summary"
+ identifier = "summary"
+
+ def render(self):
+ if self.rendered:
+ return
+ report_html = self._model.generate_report_html()
+ self.children = [ipw.HTML(report_html)]
+ self.rendered = True
diff --git a/src/aiidalab_qe/app/result/summary_viewer.py b/src/aiidalab_qe/app/result/summary_viewer.py
deleted file mode 100644
index c0a64af02..000000000
--- a/src/aiidalab_qe/app/result/summary_viewer.py
+++ /dev/null
@@ -1,238 +0,0 @@
-import ipywidgets as ipw
-
-from aiida_quantumespresso.workflows.pw.bands import PwBandsWorkChain
-
-FUNCTIONAL_LINK_MAP = {
- "PBE": "https://journals.aps.org/prl/abstract/10.1103/PhysRevLett.77.3865",
- "PBEsol": "https://journals.aps.org/prl/abstract/10.1103/PhysRevLett.100.136406",
-}
-
-PSEUDO_LINK_MAP = {
- "SSSP": "https://www.materialscloud.org/discover/sssp/table/efficiency",
- "PseudoDojo": "http://www.pseudo-dojo.org/",
-}
-
-FUNCTIONAL_REPORT_MAP = {
- "LDA": "local density approximation (LDA)",
- "PBE": "generalized gradient approximation of Perdew-Burke-Ernzerhof (PBE)",
- "PBEsol": "the revised generalized gradient approximation of Perdew-Burke-Ernzerhof (PBE) for solids",
-}
-
-# Periodicity
-PERIODICITY_MAPPING = {
- (True, True, True): "xyz",
- (True, True, False): "xy",
- (True, False, False): "x",
- (False, False, False): "molecule",
-}
-
-VDW_CORRECTION_VERSION = {
- 3: "Grimme-D3",
- 4: "Grimme-D3BJ",
- 5: "Grimme-D3M",
- 6: "Grimme-D3MBJ",
- "ts-vdw": "Tkatchenko-Scheffler",
- "none": "None",
-}
-
-
-def generate_report_parameters(qeapp_wc):
- """Generate the report parameters from the ui parameters and workchain's input.
-
- Parameters extracted from ui parameters, directly from the widgets,
- such as the ``pseudo_family`` and ``relax_type``.
-
- Parameters extracted from workchain's inputs, such as the ``energy_cutoff_wfc``
- and ``energy_cutoff_rho``.
-
- Return a dictionary of the parameters.
- """
- from aiida.orm.utils.serialize import deserialize_unsafe
-
- ui_parameters = qeapp_wc.base.extras.get("ui_parameters", {})
- if isinstance(ui_parameters, str):
- ui_parameters = deserialize_unsafe(ui_parameters)
- # Construct the report parameters needed for the report
- # drop support for old ui parameters
- if "workchain" not in ui_parameters:
- return {}
- report = {
- "relaxed": None
- if ui_parameters["workchain"]["relax_type"] == "none"
- else ui_parameters["workchain"]["relax_type"],
- "relax_method": ui_parameters["workchain"]["relax_type"],
- "electronic_type": ui_parameters["workchain"]["electronic_type"],
- "material_magnetic": ui_parameters["workchain"]["spin_type"],
- "protocol": ui_parameters["workchain"]["protocol"],
- "initial_magnetic_moments": ui_parameters["advanced"][
- "initial_magnetic_moments"
- ],
- "properties": ui_parameters["workchain"]["properties"],
- }
- #
- report.update(
- {
- "bands_computed": "bands" in ui_parameters["workchain"]["properties"],
- "pdos_computed": "pdos" in ui_parameters["workchain"]["properties"],
- }
- )
- # update pseudo family information to report
- pseudo_family = ui_parameters["advanced"].get("pseudo_family")
- pseudo_family_info = pseudo_family.split("/")
- pseudo_library = pseudo_family_info[0]
- functional = pseudo_family_info[2]
- if pseudo_library == "SSSP":
- pseudo_protocol = pseudo_family_info[3]
- elif pseudo_library == "PseudoDojo":
- pseudo_protocol = pseudo_family_info[4]
- report.update(
- {
- "pseudo_family": pseudo_family,
- "pseudo_library": pseudo_library,
- "pseudo_version": pseudo_family_info[1],
- "functional": functional,
- "pseudo_protocol": pseudo_protocol,
- "pseudo_link": PSEUDO_LINK_MAP[pseudo_library],
- "functional_link": FUNCTIONAL_LINK_MAP[functional],
- }
- )
- # Extract the pw calculation parameters from the workchain's inputs
- # energy_cutoff is same for all pw calculations when pseudopotentials are fixed
- # as well as the smearing settings (semaring and degauss) and scf kpoints distance
- # read from the first pw calculation of relax workflow.
- # It is safe then to extract these parameters from the first pw calculation, since the
- # builder is anyway set with subworkchain inputs even it is not run which controlled by
- # the properties inputs.
- pw_parameters = qeapp_wc.inputs.relax.base.pw.parameters.get_dict()
- energy_cutoff_wfc = pw_parameters["SYSTEM"]["ecutwfc"]
- energy_cutoff_rho = pw_parameters["SYSTEM"]["ecutrho"]
- occupation = pw_parameters["SYSTEM"]["occupations"]
- scf_kpoints_distance = (
- qeapp_wc.inputs.relax.base.kpoints_distance.base.attributes.get("value")
- )
- report.update(
- {
- "energy_cutoff_wfc": energy_cutoff_wfc,
- "energy_cutoff_rho": energy_cutoff_rho,
- "occupation_type": occupation,
- "scf_kpoints_distance": scf_kpoints_distance,
- }
- )
- if occupation == "smearing":
- report["degauss"] = pw_parameters["SYSTEM"]["degauss"]
- report["smearing"] = pw_parameters["SYSTEM"]["smearing"]
- report["tot_charge"] = pw_parameters["SYSTEM"].get("tot_charge", 0.0)
- report["vdw_corr"] = VDW_CORRECTION_VERSION.get(
- pw_parameters["SYSTEM"].get("dftd3_version"),
- pw_parameters["SYSTEM"].get("vdw_corr", "none"),
- )
- report["periodicity"] = PERIODICITY_MAPPING.get(
- qeapp_wc.inputs.structure.pbc, "xyz"
- )
-
- # Spin-Oribit coupling
- report["spin_orbit"] = pw_parameters["SYSTEM"].get("lspinorb", False)
-
- # DFT+U
- hubbard_dict = ui_parameters["advanced"].pop("hubbard_parameters", None)
- if hubbard_dict:
- hubbard_parameters = hubbard_dict["hubbard_u"]
- report["hubbard_u"] = hubbard_parameters
- report["tot_magnetization"] = pw_parameters["SYSTEM"].get(
- "tot_magnetization", False
- )
-
- # hard code bands and pdos
- if "bands" in qeapp_wc.inputs:
- report["bands_kpoints_distance"] = PwBandsWorkChain.get_protocol_inputs(
- report["protocol"]
- )["bands_kpoints_distance"]
-
- if "pdos" in qeapp_wc.inputs:
- report["nscf_kpoints_distance"] = (
- qeapp_wc.inputs.pdos.nscf.kpoints_distance.base.attributes.get("value")
- )
- return report
-
-
-def _generate_report_html(report):
- """Read from the bulider parameters and generate a html for reporting
- the inputs for the `QeAppWorkChain`.
- """
- from importlib.resources import files
-
- from jinja2 import Environment
-
- from aiidalab_qe.app.static import styles, templates
-
- def _fmt_yes_no(truthy):
- return "Yes" if truthy else "No"
-
- env = Environment()
- env.filters.update(
- {
- "fmt_yes_no": _fmt_yes_no,
- }
- )
- template = files(templates).joinpath("workflow_summary.jinja").read_text()
- style = files(styles).joinpath("style.css").read_text()
- report = {key: value for key, value in report.items() if value is not None}
-
- return env.from_string(template).render(style=style, **report)
-
-
-def generate_report_text(report_dict):
- """Generate a text for reporting the inputs for the `QeAppWorkChain`
-
- :param report_dict: dictionary generated by the `generate_report_dict` function.
- """
-
- report_string = (
- "All calculations are performed within the density-functional "
- "theory formalism as implemented in the Quantum ESPRESSO code. "
- "The pseudopotential for each element is extracted from the "
- f'{report_dict["Pseudopotential library"][0]} '
- "library. The wave functions "
- "of the valence electrons are expanded in a plane wave basis set, using an "
- "energy cutoff equal to "
- f'{round(report_dict["Plane wave energy cutoff (wave functions)"][0])} Ry '
- "for the wave functions and "
- f'{round(report_dict["Plane wave energy cutoff (charge density)"][0])} Ry '
- "for the charge density and potential. "
- "The exchange-correlation energy is "
- "calculated using the "
- f'{FUNCTIONAL_REPORT_MAP[report_dict["Functional"][0]]}. '
- "A Monkhorst-Pack mesh is used for sampling the Brillouin zone, where the "
- "distance between the k-points is set to "
- )
- kpoints_distances = []
- kpoints_calculations = []
-
- for calc in ("SCF", "NSCF", "Bands"):
- if f"K-point mesh distance ({calc})" in report_dict:
- kpoints_distances.append(
- str(report_dict[f"K-point mesh distance ({calc})"][0])
- )
- kpoints_calculations.append(calc)
-
- report_string += ", ".join(kpoints_distances)
- report_string += " for the "
- report_string += ", ".join(kpoints_calculations)
- report_string += " calculation"
- if len(kpoints_distances) > 1:
- report_string += "s, respectively"
- report_string += "."
-
- return report_string
-
-
-class SummaryView(ipw.VBox):
- def __init__(self, wc_node, **kwargs):
- self.report = generate_report_parameters(wc_node)
- self.report_html = _generate_report_html(self.report)
-
- self.summary_view = ipw.HTML(self.report_html)
- super().__init__(
- children=[self.summary_view],
- **kwargs,
- )
diff --git a/src/aiidalab_qe/app/result/viewer/__init__.py b/src/aiidalab_qe/app/result/viewer/__init__.py
new file mode 100644
index 000000000..3c9110cb1
--- /dev/null
+++ b/src/aiidalab_qe/app/result/viewer/__init__.py
@@ -0,0 +1,7 @@
+from .model import WorkChainViewerModel
+from .viewer import WorkChainViewer
+
+__all__ = [
+ "WorkChainViewerModel",
+ "WorkChainViewer",
+]
diff --git a/src/aiidalab_qe/app/result/viewer/model.py b/src/aiidalab_qe/app/result/viewer/model.py
new file mode 100644
index 000000000..2628d21c0
--- /dev/null
+++ b/src/aiidalab_qe/app/result/viewer/model.py
@@ -0,0 +1,17 @@
+import ipywidgets as ipw
+
+from aiidalab_qe.common.mixins import HasModels, HasProcess
+from aiidalab_qe.common.mvc import Model
+from aiidalab_qe.common.panel import ResultsModel
+
+
+class WorkChainViewerModel(
+ Model,
+ HasModels[ResultsModel],
+ HasProcess,
+):
+ def _link_model(self, model: ResultsModel):
+ ipw.dlink(
+ (self, "monitor_counter"),
+ (model, "monitor_counter"),
+ )
diff --git a/src/aiidalab_qe/app/result/workchain_viewer.py b/src/aiidalab_qe/app/result/viewer/outputs.py
similarity index 55%
rename from src/aiidalab_qe/app/result/workchain_viewer.py
rename to src/aiidalab_qe/app/result/viewer/outputs.py
index 48cf9f734..b2cabd47e 100644
--- a/src/aiidalab_qe/app/result/workchain_viewer.py
+++ b/src/aiidalab_qe/app/result/viewer/outputs.py
@@ -1,5 +1,6 @@
+from __future__ import annotations
+
import shutil
-import typing as t
from importlib.resources import files
from pathlib import Path
from tempfile import TemporaryDirectory
@@ -13,144 +14,9 @@
from aiida import orm
from aiida.cmdline.utils.common import get_workchain_report
from aiida.common import LinkType
-from aiida.orm.utils.serialize import deserialize_unsafe
from aiidalab_qe.app.static import styles, templates
-from aiidalab_qe.app.utils import get_entry_items
-from aiidalab_widgets_base import register_viewer_widget
-from aiidalab_widgets_base.viewers import StructureDataViewer
-
-from .summary_viewer import SummaryView
-from .utils.download_data import DownloadDataWidget
-
-
-@register_viewer_widget("process.workflow.workchain.WorkChainNode.")
-class WorkChainViewer(ipw.VBox):
- _results_shown = tl.Set()
- process_uuid = tl.Unicode(allow_none=True)
-
- def __init__(self, node, **kwargs):
- if node.process_label != "QeAppWorkChain":
- super().__init__()
- return
-
- self.process_uuid = node.uuid
- # In the new version of the plugin, the ui_parameters are stored as a yaml string
- # which is then converted to a dictionary
- ui_parameters = node.base.extras.get("ui_parameters", {})
- if isinstance(ui_parameters, str):
- ui_parameters = deserialize_unsafe(ui_parameters)
-
- self.title = ipw.HTML(
- f"""
-
- QE App Workflow (pk: {node.pk}) —
- {node.inputs.structure.get_formula()}
-
- """
- )
- self.workflows_summary = SummaryView(node)
-
- self.summary_tab = ipw.VBox(children=[self.workflows_summary])
- # Only the summary tab is shown by default
- self.result_tabs = ipw.Tab(children=[self.summary_tab])
-
- self.result_tabs.set_title(0, "Workflow Summary")
-
- # get plugin result panels
- # and save them the results dictionary
- self.results = {}
- entries = get_entry_items("aiidalab_qe.properties", "result")
- for identifier, entry_point in entries.items():
- result = entry_point(node)
- self.results[identifier] = result
- self.results[identifier].identifier = identifier
-
- # An ugly fix to the structure appearance problem
- # https://github.com/aiidalab/aiidalab-qe/issues/69
- def on_selected_index_change(change):
- index = change["new"]
- # Accessing the viewer only if the corresponding tab is present.
- if self.result_tabs._titles[str(index)] == "Final Geometry":
- self.structure_tab._viewer.handle_resize()
-
- def toggle_camera():
- """Toggle camera between perspective and orthographic."""
- self.structure_tab._viewer.camera = (
- "perspective"
- if self.structure_tab._viewer.camera == "orthographic"
- else "orthographic"
- )
-
- toggle_camera()
- toggle_camera()
-
- self.result_tabs.observe(on_selected_index_change, "selected_index")
- self._update_view()
- super().__init__(
- children=[self.title, self.result_tabs],
- **kwargs,
- )
- # self.process_monitor = ProcessMonitor(
- # timeout=1.0,
- # on_sealed=[
- # self._update_view,
- # ],
- # )
- # ipw.dlink((self, "process_uuid"), (self.process_monitor, "value"))
-
- @property
- def node(self):
- """Load the workchain node using the process_uuid.
- Because the workchain node is used in another thread inside the process monitor,
- we need to load the node from the database, instead of passing the node object.
- Otherwise, we will get a "Instance is not persistent" error.
- """
- return orm.load_node(self.process_uuid)
-
- def _update_view(self):
- with self.hold_trait_notifications():
- node = self.node
- if node.is_finished:
- self._show_workflow_output()
- # if the structure is present in the workchain,
- # the structure tab will be added.
- if "structure" not in self._results_shown and "structure" in node.outputs:
- self._show_structure()
- self.result_tabs.children += (self.structure_tab,)
- # index of the last tab
- index = len(self.result_tabs.children) - 1
- self.result_tabs.set_title(index, "Final Geometry")
- self._results_shown.add("structure")
-
- # update the plugin specific results
- for result in self.results.values():
- # check if the result is already shown
- if result.identifier not in self._results_shown:
- # check if the all required results are in the outputs
- results_ready = [
- label in node.outputs for label in result.workchain_labels
- ]
- if all(results_ready):
- result._update_view()
- self._results_shown.add(result.identifier)
- # add this plugin result panel
- self.result_tabs.children += (result,)
- # index of the last tab
- index = len(self.result_tabs.children) - 1
- self.result_tabs.set_title(index, result.title)
-
- def _show_structure(self):
- """Show the structure of the workchain."""
- self.structure_tab = StructureDataViewer(structure=self.node.outputs.structure)
-
- def _show_workflow_output(self):
- self.workflows_output = WorkChainOutputs(self.node)
-
- self.result_tabs.children[0].children = [
- self.workflows_summary,
- self.workflows_output,
- ]
+from ..utils.download_data import DownloadDataWidget
class WorkChainOutputs(ipw.VBox):
@@ -300,7 +166,7 @@ def _prepare_calcjob_io(cls, node: orm.WorkChainNode, root_folder: Path):
counter += 1
@staticmethod
- def _get_final_calcjob(node: orm.WorkChainNode) -> t.Union[None, orm.CalcJobNode]:
+ def _get_final_calcjob(node: orm.WorkChainNode) -> orm.CalcJobNode | None:
"""Get the final calculation job node called by a work chain node.
:param node: Work chain node.
diff --git a/src/aiidalab_qe/app/result/viewer/viewer.py b/src/aiidalab_qe/app/result/viewer/viewer.py
new file mode 100644
index 000000000..731263fcf
--- /dev/null
+++ b/src/aiidalab_qe/app/result/viewer/viewer.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+import ipywidgets as ipw
+import traitlets as tl
+
+from aiidalab_qe.app.result.summary.model import WorkChainSummaryModel
+from aiidalab_qe.app.utils import get_entry_items
+from aiidalab_qe.common.panel import ResultsPanel
+from aiidalab_widgets_base import register_viewer_widget
+
+from ..structure import StructureResults, StructureResultsModel
+from ..summary import WorkChainSummary
+from .model import WorkChainViewerModel
+from .outputs import WorkChainOutputs
+
+
+@register_viewer_widget("process.workflow.workchain.WorkChainNode.")
+class WorkChainViewer(ipw.VBox):
+ _results_shown = tl.Set()
+
+ def __init__(self, node, model: WorkChainViewerModel, **kwargs):
+ from aiidalab_qe.common.widgets import LoadingWidget
+
+ super().__init__(
+ children=[LoadingWidget("Loading result panels")],
+ **kwargs,
+ )
+
+ self._model = model
+ self._model.process_uuid = node.uuid
+
+ self.rendered = False
+
+ summary_model = WorkChainSummaryModel()
+ summary_model.process_uuid = node.uuid
+ self.summary = WorkChainSummary(model=summary_model)
+ self._model.add_model("summary", summary_model)
+
+ self.results: dict[str, ResultsPanel] = {
+ "summary": self.summary,
+ }
+
+ # TODO consider refactoring structure relaxation panel as a plugin
+ if "relax" in self._model.properties:
+ self._add_structure_panel()
+
+ self._fetch_plugin_results()
+
+ def render(self):
+ if self.rendered:
+ return
+
+ node = self._model.fetch_process_node()
+
+ self.title = ipw.HTML()
+
+ title = "
"
+ if node:
+ formula = node.inputs.structure.get_formula()
+ title += f"\nQE App Workflow (pk: {node.pk}) — {formula}
"
+ else:
+ title += "\nQE App Workflow
"
+
+ self.title.value = title
+
+ self.tabs = ipw.Tab(selected_index=None)
+
+ self.children = [
+ self.title,
+ self.tabs,
+ ]
+
+ self.rendered = True
+
+ self._update_tabs()
+
+ if node and node.is_finished:
+ self._add_workflow_output_widget()
+
+ def _update_tabs(self):
+ children = []
+ titles = []
+ for identifier, model in [*self._model.get_models()]:
+ if model.include:
+ results = self.results[identifier]
+ titles.append(results.title)
+ children.append(results)
+ self.tabs.children = children
+ for i, title in enumerate(titles):
+ self.tabs.set_title(i, title)
+ self.tabs.selected_index = 0
+ self.summary.render()
+
+ def _add_workflow_output_widget(self):
+ process_node = self._model.fetch_process_node()
+ self.summary.children += (WorkChainOutputs(node=process_node),)
+
+ def _add_structure_panel(self):
+ structure_model = StructureResultsModel()
+ structure_model.process_uuid = self._model.process_uuid
+ self.structure_results = StructureResults(model=structure_model)
+ identifier = self.structure_results.identifier
+ self._model.add_model(identifier, structure_model)
+ self.results[identifier] = self.structure_results
+
+ def _fetch_plugin_results(self):
+ entries = get_entry_items("aiidalab_qe.properties", "result")
+ for identifier, entry in entries.items():
+ for key in ("panel", "model"):
+ if key not in entry:
+ raise ValueError(
+ f"Entry {identifier} is missing the results '{key}' key"
+ )
+ panel = entry["panel"]
+ model = entry["model"]()
+ model.process_uuid = self._model.process_uuid
+ self.results[identifier] = panel(
+ identifier=identifier,
+ model=model,
+ )
+ self._model.add_model(identifier, model)
diff --git a/src/aiidalab_qe/app/static/styles/custom.css b/src/aiidalab_qe/app/static/styles/custom.css
index 0cf6e78a3..053cf8b92 100644
--- a/src/aiidalab_qe/app/static/styles/custom.css
+++ b/src/aiidalab_qe/app/static/styles/custom.css
@@ -38,9 +38,21 @@
margin-bottom: 0.5em;
}
-#loading {
- text-align: center;
+.loading {
+ margin: 0 auto;
+ padding: 5px;
font-size: large;
+ justify-content: center;
+}
+
+.warning {
+ color: red;
+ font-weight: bold;
+}
+
+.pseudo-text {
+ line-height: 140%;
+ padding: 5px 0;
}
footer {
diff --git a/src/aiidalab_qe/app/static/templates/about.jinja b/src/aiidalab_qe/app/static/templates/about.jinja
index 0b617a231..6203b2124 100644
--- a/src/aiidalab_qe/app/static/templates/about.jinja
+++ b/src/aiidalab_qe/app/static/templates/about.jinja
@@ -1,10 +1,9 @@
- The Quantum ESPRESSO app
- (or QE app for short) is a graphical front end for calculating materials properties using
- Quantum ESPRESSO (QE). Each property is calculated by workflows powered by the
- AiiDA engine, and maintained in the
- aiida-quantumespresso plugin and many other plugins developed by the community.
- for AiiDA.
+ The Quantum ESPRESSO app (or QE app for short) is a graphical front end for calculating materials properties using
+ Quantum ESPRESSO (QE).
+ Each property is calculated by workflows powered by the AiiDA
+ engine, and maintained in the aiida-quantumespresso plugin and many other plugins developed by the AiiDA community.
diff --git a/src/aiidalab_qe/app/static/templates/guide.jinja b/src/aiidalab_qe/app/static/templates/guide.jinja
index 14b10f914..d72d7beef 100644
--- a/src/aiidalab_qe/app/static/templates/guide.jinja
+++ b/src/aiidalab_qe/app/static/templates/guide.jinja
@@ -23,7 +23,11 @@
- Completed workflows can be selected at the top of the app.
+ Completed workflows can be viewed in the Job History section.
+
+
+
+ To start a new calculation in a separate tab, click the Start New Calculation button.
diff --git a/src/aiidalab_qe/app/structure/__init__.py b/src/aiidalab_qe/app/structure/__init__.py
index cb5940ccf..998be5cf2 100644
--- a/src/aiidalab_qe/app/structure/__init__.py
+++ b/src/aiidalab_qe/app/structure/__init__.py
@@ -6,17 +6,17 @@
import pathlib
import ipywidgets as ipw
-import traitlets as tl
-import aiida
-from aiida_quantumespresso.data.hubbard_structure import HubbardStructureData
+from aiidalab_qe.app.structure.model import StructureStepModel
from aiidalab_qe.app.utils import get_entry_items
-from aiidalab_qe.common import AddingTagsEditor
+from aiidalab_qe.common import (
+ AddingTagsEditor,
+ LazyLoadedOptimade,
+ LazyLoadedStructureBrowser,
+)
from aiidalab_widgets_base import (
BasicCellEditor,
BasicStructureEditor,
- OptimadeQueryWidget,
- StructureBrowserWidget,
StructureExamplesWidget,
StructureManagerWidget,
StructureUploadWidget,
@@ -47,51 +47,77 @@ class StructureSelectionStep(ipw.VBox, WizardAppWidgetStep):
structure importers and the structure editors can be extended by plugins.
"""
- structure = tl.Instance(aiida.orm.StructureData, allow_none=True)
- confirmed_structure = tl.Instance(aiida.orm.StructureData, allow_none=True)
+ def __init__(self, model: StructureStepModel, **kwargs):
+ from aiidalab_qe.common.widgets import LoadingWidget
+
+ super().__init__(
+ children=[LoadingWidget("Loading structure selection step")],
+ **kwargs,
+ )
+
+ self._model = model
+ self._model.observe(
+ self._on_confirmation_change,
+ "confirmed",
+ )
+ self._model.observe(
+ self._on_input_structure_change,
+ "input_structure",
+ )
+
+ self.rendered = False
+
+ def render(self):
+ """docstring"""
+ if self.rendered:
+ return
- def __init__(self, description=None, **kwargs):
importers = [
StructureUploadWidget(title="Upload file"),
- OptimadeQueryWidget(embedded=False),
- StructureBrowserWidget(
- title="AiiDA database",
- query_types=(
- aiida.orm.StructureData,
- aiida.orm.CifData,
- HubbardStructureData,
- ),
- ),
+ LazyLoadedOptimade(title="OPTIMADE"),
+ LazyLoadedStructureBrowser(title="AiiDA database"),
StructureExamplesWidget(title="From Examples", examples=Examples),
]
- # add plugin specific structure importers
- entries = get_entry_items("aiidalab_qe.properties", "importer")
- importers.extend([entry_point() for entry_point in entries.values()])
- # add plugin specific structure editors
+
+ plugin_importers = get_entry_items("aiidalab_qe.properties", "importer")
+ importers.extend([importer() for importer in plugin_importers.values()])
+
editors = [
BasicCellEditor(title="Edit cell"),
BasicStructureEditor(title="Edit structure"),
AddingTagsEditor(title="Edit StructureData"),
]
- entries = get_entry_items("aiidalab_qe.properties", "editor")
- editors.extend([entry_point() for entry_point in entries.values()])
- #
+
+ plugin_editors = get_entry_items("aiidalab_qe.properties", "editor")
+ editors.extend([editor() for editor in plugin_editors.values()])
+
self.manager = StructureManagerWidget(
importers=importers,
editors=editors,
node_class="StructureData",
storable=False,
- configuration_tabs=["Cell", "Selection", "Appearance", "Download"],
+ configuration_tabs=[
+ "Cell",
+ "Selection",
+ "Appearance",
+ "Download",
+ ],
)
- if description is None:
- description = ipw.HTML(
- """
-
Select a structure from one of the following sources and then click
- "Confirm" to go to the next step.
- """
- )
- self.description = description
+ if self._model.confirmed: # loaded from a process
+ # NOTE important to do this prior to setting up the links
+ # to avoid an override of the structure in the model,
+ # which in turn would trigger a reset of the model
+ self.manager.input_structure = self._model.input_structure
+
+ ipw.dlink(
+ (self.manager, "structure_node"),
+ (self._model, "input_structure"),
+ )
+ ipw.link(
+ (self._model, "manager_output"),
+ (self.manager.output, "value"),
+ )
self.structure_name_text = ipw.Text(
placeholder="[No structure selected]",
@@ -99,88 +125,71 @@ def __init__(self, description=None, **kwargs):
disabled=True,
layout=ipw.Layout(width="auto", flex="1 1 auto"),
)
+ ipw.dlink(
+ (self._model, "structure_name"),
+ (self.structure_name_text, "value"),
+ )
self.confirm_button = ipw.Button(
description="Confirm",
tooltip="Confirm the currently selected structure and go to the next step.",
button_style="success",
icon="check-circle",
- disabled=True,
layout=ipw.Layout(width="auto"),
)
+ ipw.dlink(
+ (self, "state"),
+ (self.confirm_button, "disabled"),
+ lambda state: state != self.State.CONFIGURED,
+ )
self.confirm_button.on_click(self.confirm)
- self.message_area = ipw.HTML()
-
- # Create directional link from the (read-only) 'structure_node' traitlet of the
- # structure manager to our 'structure' traitlet:
- ipw.dlink((self.manager, "structure_node"), (self, "structure"))
- super().__init__(
- children=[
- self.description,
- self.manager,
- self.structure_name_text,
- self.message_area,
- self.confirm_button,
- ],
- **kwargs,
+ self.message_area = ipw.HTML()
+ ipw.dlink(
+ (self._model, "message_area"),
+ (self.message_area, "value"),
)
- @tl.default("state")
- def _default_state(self):
- return self.State.INIT
+ self.children = [
+ ipw.HTML("""
+
+ Select a structure from one of the following sources and then
+ click "Confirm" to go to the next step.
+
+ """),
+ self.manager,
+ self.structure_name_text,
+ self.message_area,
+ self.confirm_button,
+ ]
- def _update_state(self):
- if self.structure is None:
- if self.confirmed_structure is None:
- self.state = self.State.READY
- else:
- self.state = self.State.SUCCESS
- else:
- if self.confirmed_structure is None:
- self.state = self.State.CONFIGURED
- else:
- self.state = self.State.SUCCESS
-
- @tl.observe("structure")
- def _observe_structure(self, change):
- structure = change["new"]
- with self.hold_trait_notifications():
- if structure is None:
- self.structure_name_text.value = ""
- self.message_area.value = ""
- else:
- self.structure_name_text.value = str(self.structure.get_formula())
- self._update_state()
-
- @tl.observe("confirmed_structure")
- def _observe_confirmed_structure(self, _):
- with self.hold_trait_notifications():
- self._update_state()
-
- @tl.observe("state")
- def _observe_state(self, change):
- with self.hold_trait_notifications():
- state = change["new"]
- self.confirm_button.disabled = state != self.State.CONFIGURED
- self.manager.disabled = state is self.State.SUCCESS
+ self.rendered = True
+
+ def is_saved(self):
+ return self._model.confirmed
def confirm(self, _=None):
self.manager.store_structure()
- self.confirmed_structure = self.structure
- self.message_area.value = ""
-
- def is_saved(self):
- """Check if the current structure is saved.
- That all changes are confirmed."""
- return self.confirmed_structure == self.structure
+ self._model.message_area = ""
+ self._model.confirm()
def can_reset(self):
- return self.confirmed_structure is not None
-
- def reset(self): # unconfirm
- """Reset the widget to its initial state."""
- self.confirmed_structure = None
- self.manager.structure = None
- self.manager.viewer.structure = None
- self.manager.output.value = ""
+ return self._model.confirmed
+
+ def reset(self):
+ self._model.reset()
+
+ def _on_input_structure_change(self, _):
+ self._model.update_widget_text()
+ self._update_state()
+
+ def _on_confirmation_change(self, _):
+ self._update_state()
+
+ def _update_state(self):
+ if self._model.confirmed:
+ self.state = self.State.SUCCESS
+ elif self._model.input_structure is None:
+ self.state = self.State.READY
+ else:
+ self.state = self.State.CONFIGURED
diff --git a/src/aiidalab_qe/app/structure/model.py b/src/aiidalab_qe/app/structure/model.py
new file mode 100644
index 000000000..617222ebb
--- /dev/null
+++ b/src/aiidalab_qe/app/structure/model.py
@@ -0,0 +1,28 @@
+import traitlets as tl
+
+from aiidalab_qe.common.mixins import Confirmable, HasInputStructure
+from aiidalab_qe.common.mvc import Model
+
+
+class StructureStepModel(
+ Model,
+ HasInputStructure,
+ Confirmable,
+):
+ structure_name = tl.Unicode("")
+ manager_output = tl.Unicode("")
+ message_area = tl.Unicode("")
+
+ def update_widget_text(self):
+ if not self.has_structure:
+ self.structure_name = ""
+ self.message_area = ""
+ else:
+ self.manager_output = ""
+ self.structure_name = str(self.input_structure.get_formula())
+
+ def reset(self):
+ self.input_structure = None
+ self.structure_name = ""
+ self.manager_output = ""
+ self.message_area = ""
diff --git a/src/aiidalab_qe/app/submission/__init__.py b/src/aiidalab_qe/app/submission/__init__.py
index de0272b63..d337d1e7d 100644
--- a/src/aiidalab_qe/app/submission/__init__.py
+++ b/src/aiidalab_qe/app/submission/__init__.py
@@ -5,98 +5,109 @@
from __future__ import annotations
-import os
-
import ipywidgets as ipw
import traitlets as tl
-from aiida import orm
-from aiida.common import NotExistent
-from aiida.engine import ProcessBuilderNamespace, submit
from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS
from aiidalab_qe.app.utils import get_entry_items
from aiidalab_qe.common.setup_codes import QESetupWidget
from aiidalab_qe.common.setup_pseudos import PseudosInstallWidget
from aiidalab_qe.common.widgets import (
+ LoadingWidget,
PwCodeResourceSetupWidget,
QEAppComputationalResourcesWidget,
)
-from aiidalab_qe.workflows import QeAppWorkChain
from aiidalab_widgets_base import WizardAppWidgetStep
+from .code import CodeModel, PluginCodes, PwCodeModel
+from .model import SubmissionStepModel
+
+DEFAULT: dict = DEFAULT_PARAMETERS # type: ignore
+
class SubmitQeAppWorkChainStep(ipw.VBox, WizardAppWidgetStep):
"""Step for submission of a bands workchain."""
- codes_title = ipw.HTML(
- """
-
Codes
"""
- )
- codes_help = ipw.HTML(
- """ Select the code to use for running the calculations. The codes
- on the local machine (localhost) are installed by default, but you can
- configure new ones on potentially more powerful machines by clicking on
- "Setup new code".
"""
- )
- process_label_help = ipw.HTML(
- """
-
Labeling Your Job
-
Label your job and provide a brief description. These details help identify the job later and make the search process easier. While optional, adding a description is recommended for better clarity.
-
"""
- )
-
- # This number provides a rough estimate for how many MPI tasks are needed
- # for a given structure.
- NUM_SITES_PER_MPI_TASK_DEFAULT = 6
-
- # Warn the user if they are trying to run calculations for a large
- # structure on localhost.
- RUN_ON_LOCALHOST_NUM_SITES_WARN_THRESHOLD = 10
- RUN_ON_LOCALHOST_VOLUME_WARN_THRESHOLD = 1000 # \AA^3
-
- # Put a limit on how many MPI tasks you want to run per k-pool by default
- MAX_MPI_PER_POOL = 20
-
- input_structure = tl.Instance(orm.StructureData, allow_none=True)
- process = tl.Instance(orm.WorkChainNode, allow_none=True)
previous_step_state = tl.UseEnum(WizardAppWidgetStep.State)
- input_parameters = tl.Dict()
- internal_submission_blockers = tl.List(tl.Unicode())
- external_submission_blockers = tl.List(tl.Unicode())
- def __init__(self, qe_auto_setup=True, **kwargs):
- self._submission_blocker_messages = ipw.HTML()
- self._submission_warning_messages = ipw.HTML()
+ def __init__(self, model: SubmissionStepModel, qe_auto_setup=True, **kwargs):
+ super().__init__(
+ children=[LoadingWidget("Loading workflow submission step")],
+ **kwargs,
+ )
- self.pw_code = PwCodeResourceSetupWidget(
- description="pw.x:", default_calc_job_plugin="quantumespresso.pw"
+ self._model = model
+ self._model.observe(
+ self._on_submission,
+ "confirmed",
+ )
+ self._model.observe(
+ self._on_input_structure_change,
+ "input_structure",
+ )
+ self._model.observe(
+ self._on_input_parameters_change,
+ "input_parameters",
+ )
+ self._model.observe(
+ self._on_process_change,
+ "process",
+ )
+ self._model.observe(
+ self._on_submission_blockers_change,
+ [
+ "internal_submission_blockers",
+ "external_submission_blockers",
+ ],
+ )
+ self._model.observe(
+ self._on_installation_change,
+ ["installing_sssp", "sssp_installed"],
+ )
+ self._model.observe(
+ self._on_sssp_installed,
+ "sssp_installed",
+ )
+ self._model.observe(
+ self._on_installation_change,
+ ["installing_qe", "qe_installed"],
+ )
+ self._model.observe(
+ self._on_qe_installed,
+ "qe_installed",
)
- self.pw_code.observe(self._update_state, "value")
+ self.rendered = False
+
+ self.code_widgets: dict[str, QEAppComputationalResourcesWidget] = {}
+
+ self._install_sssp(qe_auto_setup)
+ self._set_up_qe(qe_auto_setup)
+ self._set_up_codes()
+
+ def render(self):
+ if self.rendered:
+ return
+
+ self.code_widgets_container = ipw.VBox()
- # add plugin's entry points
- self.codes = {"pw": self.pw_code}
- self.code_children = [
- self.codes_title,
- self.codes_help,
- self.pw_code,
- ]
- self.code_entries = get_entry_items("aiidalab_qe.properties", "code")
- for _, entry_point in self.code_entries.items():
- for name, code in entry_point.items():
- self.codes[name] = code
- code.observe(self._update_state, "value")
- self.code_children.append(self.codes[name])
- # set process label and description
self.process_label = ipw.Text(
- description="Label:", layout=ipw.Layout(width="auto", indent="0px")
+ description="Label:",
+ layout=ipw.Layout(width="auto", indent="0px"),
+ )
+ ipw.link(
+ (self._model, "process_label"),
+ (self.process_label, "value"),
)
self.process_description = ipw.Textarea(
- description="Description", layout=ipw.Layout(width="auto", indent="0px")
+ description="Description",
+ layout=ipw.Layout(width="auto", indent="0px"),
+ )
+ ipw.link(
+ (self._model, "process_description"),
+ (self.process_description, "value"),
)
- #
+
self.submit_button = ipw.Button(
description="Submit",
tooltip="Submit the calculation with the selected parameters.",
@@ -105,440 +116,270 @@ def __init__(self, qe_auto_setup=True, **kwargs):
layout=ipw.Layout(width="auto", flex="1 1 auto"),
disabled=True,
)
-
- self.submit_button.on_click(self._on_submit_button_clicked)
-
- # The SSSP installation status widget shows the installation status of
- # the SSSP pseudo potentials and triggers the installation in case that
- # they are not yet installed. The widget will remain in a "busy" state
- # in case that the installation was already triggered elsewhere, e.g.,
- # by the start up scripts. The submission is blocked while the
- # potentials are not yet installed.
- self.sssp_installation_status = PseudosInstallWidget(auto_start=qe_auto_setup)
- self.sssp_installation_status.observe(self._update_state, ["busy", "installed"])
- self.sssp_installation_status.observe(self._toggle_install_widgets, "installed")
-
- # The QE setup widget checks whether there are codes that match specific
- # expected labels (e.g. "pw-7.2@localhost") and triggers both the
- # installation of QE into a dedicated conda environment and the setup of
- # the codes in case that they are not already configured.
- self.qe_setup_status = QESetupWidget(auto_start=qe_auto_setup)
- self.qe_setup_status.observe(self._update_state, "busy")
- self.qe_setup_status.observe(self._toggle_install_widgets, "installed")
- self.qe_setup_status.observe(self._auto_select_code, "installed")
- self.ui_parameters = {}
-
- super().__init__(
- children=[
- *self.code_children,
- self.sssp_installation_status,
- self.qe_setup_status,
- self._submission_blocker_messages,
- self._submission_warning_messages,
- self.process_label_help,
- self.process_label,
- self.process_description,
- self.submit_button,
- ],
- **kwargs,
+ ipw.dlink(
+ (self, "state"),
+ (self.submit_button, "disabled"),
+ lambda state: state != self.State.CONFIGURED,
)
- # set default codes
- self.set_selected_codes(DEFAULT_PARAMETERS["codes"])
-
- # observe these two for the resource checking:
- self.pw_code.num_cpus.observe(self._check_resources, "value")
- self.pw_code.num_nodes.observe(self._check_resources, "value")
-
- @tl.observe("internal_submission_blockers", "external_submission_blockers")
- def _observe_submission_blockers(self, _change):
- """Observe the submission blockers and update the message area."""
- blockers = self.internal_submission_blockers + self.external_submission_blockers
- if any(blockers):
- fmt_list = "\n".join(f"{item}" for item in sorted(blockers))
- self._submission_blocker_messages.value = f"""
-
-
The submission is blocked, due to the following reason(s):
-
"""
- else:
- self._submission_blocker_messages.value = ""
-
- def _identify_submission_blockers(self):
- """Validate the resource inputs and identify blockers for the submission."""
- # Do not submit while any of the background setup processes are running.
- if self.qe_setup_status.busy or self.sssp_installation_status.busy:
- yield "Background setup processes must finish."
-
- # No pw code selected (this is ignored while the setup process is running).
- if self.pw_code.value is None and not self.qe_setup_status.busy:
- yield ("No pw code selected")
- # code related to the selected property is not installed
- properties = self.input_parameters.get("workchain", {}).get("properties", [])
- for identifer in properties:
- for name, code in self.code_entries.get(identifer, {}).items():
- if code.value is None:
- yield f"Calculating the {identifer} property requires code {name} to be set."
- # SSSP library not installed
- if not self.sssp_installation_status.installed:
- yield "The SSSP library is not installed."
-
- # check if the QEAppComputationalResourcesWidget is used
- for name, code in self.codes.items():
- # skip if the code is not displayed, convenient for the plugin developer
- if code.layout.display == "none":
- continue
- if not isinstance(code, QEAppComputationalResourcesWidget):
- yield (
- f"Error: hi, plugin developer, please use the QEAppComputationalResourcesWidget from aiidalab_qe.common.widgets for code {name}."
- )
-
- def _update_state(self, _=None):
- # If the previous step has failed, this should fail as well.
- if self.previous_step_state is self.State.FAIL:
- self.state = self.State.FAIL
- return
- # Do not interact with the user if they haven't successfully completed the previous step.
- elif self.previous_step_state is not self.State.SUCCESS:
- self.state = self.State.INIT
- return
+ self.submit_button.on_click(self.submit)
- # Process is already running.
- if self.process is not None:
- self.state = self.State.SUCCESS
- return
+ self.submission_blocker_messages = ipw.HTML()
+ ipw.dlink(
+ (self._model, "submission_blocker_messages"),
+ (self.submission_blocker_messages, "value"),
+ )
- blockers = list(self._identify_submission_blockers())
- if any(blockers):
- self.internal_submission_blockers = blockers
- self.state = self.State.READY
- return
+ self.submission_warning_messages = ipw.HTML()
+ ipw.dlink(
+ (self._model, "submission_warning_messages"),
+ (self.submission_warning_messages, "value"),
+ )
- self.internal_submission_blockers = []
- self.state = self.state.CONFIGURED
-
- def _toggle_install_widgets(self, change):
- if change["new"]:
- self.children = [
- child for child in self.children if child is not change["owner"]
- ]
-
- def _auto_select_code(self, change):
- if change["new"] and not change["old"]:
- self.set_selected_codes(DEFAULT_PARAMETERS["codes"])
-
- _ALERT_MESSAGE = """
- """
-
- def _show_alert_message(self, message, alert_class="info"):
- self._submission_warning_messages.value = self._ALERT_MESSAGE.format(
- alert_class=alert_class, message=message
- )
-
- @tl.observe("input_structure")
- def _check_resources(self, _change=None):
- """Check whether the currently selected resources will be sufficient and warn if not."""
- if not self.pw_code.value or not self.input_structure:
- return # No code selected or no structure, so nothing to do.
-
- num_cpus = self.pw_code.num_cpus.value * self.pw_code.num_nodes.value
- on_localhost = (
- orm.load_node(self.pw_code.value).computer.hostname == "localhost"
- )
- num_sites = len(self.input_structure.sites)
- volume = self.input_structure.get_cell_volume()
- try:
- localhost_cpus = len(os.sched_getaffinity(0))
- except (
- Exception
- ): # fallback, in some OS os.sched_getaffinity(0) is not supported
- localhost_cpus = os.cpu_count() # however, not so realiable in containers.
-
- large_system = (
- num_sites > self.RUN_ON_LOCALHOST_NUM_SITES_WARN_THRESHOLD
- or volume > self.RUN_ON_LOCALHOST_VOLUME_WARN_THRESHOLD
- )
-
- estimated_CPUs = self._estimate_min_cpus(
- num_sites, volume
- ) # estimated number of CPUs for a run less than 12 hours.
-
- # List of possible suggestions for warnings:
- suggestions = {
- "more_resources": f"Increase the resources (total number of CPUs should be equal or more than {min(100,estimated_CPUs)}, if possible) ",
- "change_configuration": "Review the configuration (e.g. choosing fast protocol - this will affect precision) ",
- "go_remote": "Select a code that runs on a larger machine",
- "avoid_overloading": "Reduce the number of CPUs to avoid the overloading of the local machine ",
- }
+ self.children = [
+ ipw.HTML("""
+
+
Codes
+
+ """),
+ ipw.HTML("""
+
+ Select the code to use for running the calculations. The codes on
+ the local machine (localhost) are installed by default, but you can
+ configure new ones on potentially more powerful machines by clicking
+ on "Setup new code".
+
+ """),
+ self.code_widgets_container,
+ self.sssp_installation,
+ self.qe_setup,
+ self.submission_blocker_messages,
+ self.submission_warning_messages,
+ ipw.HTML("""
+
+
Labeling Your Job
+
+ Label your job and provide a brief description. These details
+ help identify the job later and make the search process easier.
+ While optional, adding a description is recommended for better
+ clarity.
+
+
+ """),
+ self.process_label,
+ self.process_description,
+ self.submit_button,
+ ]
- alert_message = ""
- if large_system and estimated_CPUs > num_cpus:
- # This part is in common between Warnings 1 (2): (not) on localhost, big system and few cpus
- warnings_1_2 = (
- f"⚠ Warning: The selected structure is large, with {num_sites} atoms "
- f"and a volume of {int(volume)} Å3, "
- "making it computationally demanding "
- "to run at the localhost. Consider the following: "
- if on_localhost
- else "to run in a reasonable amount of time. Consider the following: "
- )
+ self.rendered = True
- # Warning 1: on localhost, big system and few cpus
- if on_localhost:
- alert_message += (
- warnings_1_2
- + ""
- + suggestions["more_resources"]
- + suggestions["change_configuration"]
- + "
"
- )
- # Warning 2: not on localhost, big system and few cpus
- else:
- alert_message += (
- warnings_1_2
- + ""
- + suggestions["go_remote"]
- + suggestions["more_resources"]
- + suggestions["change_configuration"]
- + "
"
- )
- if on_localhost and num_cpus / localhost_cpus > 0.8:
- # Warning-3: on localhost, more than half of the available cpus
- alert_message += (
- "⚠ Warning: the selected pw.x code will run locally, but "
- f"the number of requested CPUs ({num_cpus}) is larger than the 80% of the available resources ({localhost_cpus}). "
- "Please be sure that your local "
- "environment has enough free CPUs for the calculation. Consider the following: "
- ""
- + suggestions["avoid_overloading"]
- + suggestions["go_remote"]
- + "
"
- )
+ # Render any active codes
+ self._model.get_code("dft", "pw").activate()
+ for _, code in self._model.get_code_models(flat=True):
+ if code.is_active:
+ self._toggle_code(code)
- if not (on_localhost and num_cpus / localhost_cpus) > 0.8 and not (
- large_system and estimated_CPUs > num_cpus
- ):
- self._submission_warning_messages.value = ""
- else:
- self._show_alert_message(
- message=alert_message,
- alert_class="warning",
- )
+ def submit(self, _=None):
+ self._model.confirm()
- @tl.observe("state")
- def _observe_state(self, change):
+ def reset(self):
with self.hold_trait_notifications():
- self.submit_button.disabled = change["new"] != self.State.CONFIGURED
+ self._model.reset()
+ self._model.set_selected_codes()
- @tl.observe("previous_step_state", "input_parameters")
- def _observe_input_structure(self, _):
+ @tl.observe("previous_step_state")
+ def _on_previous_step_state_change(self, _):
self._update_state()
- self.update_codes_display()
- self._update_process_label()
- @tl.observe("process")
- def _observe_process(self, change):
- with self.hold_trait_notifications():
- process_node = change["new"]
- if process_node is not None:
- self.input_structure = process_node.inputs.structure
- self._update_state()
+ def _on_input_structure_change(self, _):
+ self._model.check_resources()
- def _on_submit_button_clicked(self, _):
- self.submit_button.disabled = True
- self.submit()
+ def _on_input_parameters_change(self, _):
+ self._model.update_active_codes()
+ self._model.update_process_label()
+ self._model.update_submission_blockers()
- def get_selected_codes(self):
- """Get the codes selected in the GUI.
+ def _on_process_change(self, _):
+ with self.hold_trait_notifications():
+ # TODO why here? Do we not populate traits earlier that would cover this?
+ if self._model.process_node is not None:
+ self._model.input_structure = self._model.process_node.inputs.structure
+ self._update_state()
- return: A dict with the code names as keys and the code UUIDs as values.
- """
- codes = {
- key: code.parameters
- for key, code in self.codes.items()
- if code.layout.display != "none"
- }
- return codes
+ def _on_submission_blockers_change(self, _):
+ self._model.update_submission_blocker_message()
+ self._update_state()
- def set_selected_codes(self, code_data):
- """Set the inputs in the GUI based on a set of codes."""
+ def _on_installation_change(self, _):
+ self._model.update_submission_blockers()
- # Codes
- def _get_code_uuid(code):
- if code is not None:
- try:
- return orm.load_code(code).uuid
- except NotExistent:
- return None
+ def _on_qe_installed(self, _):
+ self._toggle_qe_installation_widget()
+ if self._model.qe_installed:
+ self._model.refresh_codes()
- with self.hold_trait_notifications():
- for name, code in self.codes.items():
- if name not in code_data:
- continue
- # check if the code is installed and usable
- # note: if code is imported from another user, it is not usable and thus will not be
- # treated as an option in the ComputationalResourcesWidget.
- code_options = [
- o[1] for o in code.code_selection.code_select_dropdown.options
- ]
- if _get_code_uuid(code_data.get(name)["code"]) in code_options:
- # get code uuid from code label in case of using DEFAULT_PARAMETERS
- code_data.get(name)["code"] = _get_code_uuid(
- code_data.get(name)["code"]
- )
- code.parameters = code_data.get(name)
-
- def update_codes_display(self):
- """Hide code if no related property is selected."""
- # hide all codes except pw
- for name, code in self.codes.items():
- if name == "pw":
- continue
- code.layout.display = "none"
- properties = self.input_parameters.get("workchain", {}).get("properties", [])
- # show the code if the related property is selected.
- for identifer in properties:
- for code in self.code_entries.get(identifer, {}).values():
- code.layout.display = "block"
+ def _on_sssp_installed(self, _):
+ self._toggle_sssp_installation_widget()
- def submit(self, _=None):
- """Submit the work chain with the current inputs."""
- from aiida.orm.utils.serialize import serialize
+ def _on_code_activation_change(self, change):
+ self._toggle_code(change["owner"])
- builder = self._create_builder()
+ def _on_code_selection_change(self, _):
+ self._model.update_submission_blockers()
- with self.hold_trait_notifications():
- process = submit(builder)
-
- process.label = self.process_label.value
- process.description = self.process_description.value
- # since AiiDA data node may exist in the ui_parameters,
- # we serialize it to yaml
- process.base.extras.set("ui_parameters", serialize(self.ui_parameters))
- # store the workchain name in extras, this will help to filter the workchain in the future
- process.base.extras.set("workchain", self.ui_parameters["workchain"])
- process.base.extras.set("structure", self.input_structure.get_formula())
- self.process = process
+ def _on_pw_code_resource_change(self, _):
+ self._model.check_resources()
+ def _on_submission(self, _):
self._update_state()
- def _update_process_label(self) -> dict:
- """Generate a label for the work chain based on the input parameters."""
- if not self.input_structure:
- return ""
- structure_label = (
- self.input_structure.label
- if len(self.input_structure.label) > 0
- else self.input_structure.get_formula()
- )
- workchain_data = self.input_parameters.get("workchain", {"properties": []})
- properties = [p for p in workchain_data["properties"] if p != "relax"]
- # relax_info
- relax_type = workchain_data.get("relax_type", "none")
- relax_info = "unrelaxed"
- if relax_type != "none":
- relax_info = (
- "relax: atoms+cell" if "cell" in relax_type else "relax: atoms only"
- )
- # protocol_info
- protocol_and_magnetic_info = f"{workchain_data['protocol']} protocol"
- # magnetic_info
- if workchain_data["spin_type"] != "none":
- protocol_and_magnetic_info += ", magnetic"
- # properties_info
- properties_info = ""
- if properties:
- properties_info = f"→ {', '.join(properties)}"
-
- label = f"{structure_label} [{relax_info}, {protocol_and_magnetic_info}] {properties_info}".strip()
- self.process_label.value = label
-
- def _create_builder(self) -> ProcessBuilderNamespace:
- """Create the builder for the `QeAppWorkChain` submit."""
- from copy import deepcopy
-
- self.ui_parameters = deepcopy(self.input_parameters)
- # add codes and resource info into ui_parameters
- submission_parameters = self.get_submission_parameters()
- self.ui_parameters.update(submission_parameters)
- builder = QeAppWorkChain.get_builder_from_protocol(
- structure=self.input_structure,
- parameters=deepcopy(self.ui_parameters),
- )
-
- self._update_builder(builder, submission_parameters["codes"])
-
- return builder
-
- def _update_builder(self, builder, codes):
- """Update the resources and parallelization of the ``relax`` builder."""
- # update resources
- builder.relax.base.pw.metadata.options.resources = {
- "num_machines": codes.get("pw")["nodes"],
- "num_mpiprocs_per_machine": codes.get("pw")["ntasks_per_node"],
- "num_cores_per_mpiproc": codes.get("pw")["cpus_per_task"],
- }
- builder.relax.base.pw.metadata.options["max_wallclock_seconds"] = codes.get(
- "pw"
- )["max_wallclock_seconds"]
- builder.relax.base.pw.parallelization = orm.Dict(
- dict=codes["pw"]["parallelization"]
+ def _install_sssp(self, qe_auto_setup):
+ self.sssp_installation = PseudosInstallWidget(auto_start=False)
+ ipw.dlink(
+ (self.sssp_installation, "busy"),
+ (self._model, "installing_sssp"),
+ )
+ ipw.dlink(
+ (self.sssp_installation, "installed"),
+ (self._model, "installing_sssp"),
+ lambda installed: not installed,
+ )
+ ipw.dlink(
+ (self.sssp_installation, "installed"),
+ (self._model, "sssp_installed"),
+ )
+ if qe_auto_setup:
+ self.sssp_installation.refresh()
+
+ def _set_up_qe(self, qe_auto_setup):
+ self.qe_setup = QESetupWidget(auto_start=False)
+ ipw.dlink(
+ (self.qe_setup, "busy"),
+ (self._model, "installing_qe"),
+ )
+ ipw.dlink(
+ (self.qe_setup, "installed"),
+ (self._model, "installing_qe"),
+ lambda installed: not installed,
+ )
+ ipw.dlink(
+ (self.qe_setup, "installed"),
+ (self._model, "qe_installed"),
)
+ if qe_auto_setup:
+ self.qe_setup.refresh()
+
+ def _set_up_codes(self):
+ codes: PluginCodes = {
+ "dft": {
+ "pw": PwCodeModel(
+ description="pw.x",
+ default_calc_job_plugin="quantumespresso.pw",
+ code_widget_class=PwCodeResourceSetupWidget,
+ ),
+ },
+ **get_entry_items("aiidalab_qe.properties", "code"),
+ }
+ for identifier, code_models in codes.items():
+ for name, code_model in code_models.items():
+ self._model.add_code(identifier, name, code_model)
+ code_model.observe(
+ self._on_code_activation_change,
+ "is_active",
+ )
+ code_model.observe(
+ self._on_code_selection_change,
+ "selected",
+ )
- def _estimate_min_cpus(
- self, n, v, n0=9, v0=117, num_cpus0=4, t0=129.6, tmax=12 * 60 * 60, scf_cycles=5
- ):
- """
- Estimate the minimum number of CPUs required to complete a task within a given time limit.
- Parameters:
- n (int): The number of atoms in the system.
- v (float): The volume of the system.
- n0 (int, optional): Reference number of atoms. Default is 9.
- v0 (float, optional): Reference volume. Default is 117.
- num_cpus0 (int, optional): Reference number of CPUs. Default is 4.
- scf_cycles (int, optional): Reference number of SCF cycles in a relaxation. Default is 5.
-
- NB: Defaults (a part scf_cycles) are taken from a calculation done for SiO2. This is just a dummy
- and not well tested estimation, placeholder for a more rigourous one.
- """
- import numpy as np
-
- return int(
- np.ceil(
- scf_cycles * num_cpus0 * (n / n0) ** 3 * (v / v0) ** 1.5 * t0 / tmax
+ def _toggle_sssp_installation_widget(self):
+ sssp_installation_display = "none" if self._model.sssp_installed else "block"
+ self.sssp_installation.layout.display = sssp_installation_display
+
+ def _toggle_qe_installation_widget(self):
+ qe_installation_display = "none" if self._model.qe_installed else "block"
+ self.qe_setup.layout.display = qe_installation_display
+
+ def _toggle_code(self, code_model: CodeModel):
+ if not self.rendered:
+ return
+ if not code_model.is_rendered:
+ loading_message = LoadingWidget(f"Loading {code_model.name} code")
+ self.code_widgets_container.children += (loading_message,)
+ if code_model.name not in self.code_widgets:
+ code_widget = code_model.code_widget_class(
+ description=code_model.description,
+ default_calc_job_plugin=code_model.default_calc_job_plugin,
)
+ self.code_widgets[code_model.name] = code_widget
+ else:
+ code_widget = self.code_widgets[code_model.name]
+ code_widget.layout.display = "block" if code_model.is_active else "none"
+ if not code_model.is_rendered:
+ self._render_code_widget(code_model, code_widget)
+
+ def _render_code_widget(
+ self,
+ code_model: CodeModel,
+ code_widget: QEAppComputationalResourcesWidget,
+ ):
+ ipw.dlink(
+ (code_model, "options"),
+ (code_widget.code_selection.code_select_dropdown, "options"),
)
+ ipw.link(
+ (code_model, "selected"),
+ (code_widget.code_selection.code_select_dropdown, "value"),
+ )
+ ipw.dlink(
+ (code_model, "selected"),
+ (code_widget.code_selection.code_select_dropdown, "disabled"),
+ lambda selected: not selected,
+ )
+ ipw.link(
+ (code_model, "num_cpus"),
+ (code_widget.num_cpus, "value"),
+ )
+ ipw.link(
+ (code_model, "num_nodes"),
+ (code_widget.num_nodes, "value"),
+ )
+ ipw.link(
+ (code_model, "ntasks_per_node"),
+ (code_widget.resource_detail.ntasks_per_node, "value"),
+ )
+ ipw.link(
+ (code_model, "cpus_per_task"),
+ (code_widget.resource_detail.cpus_per_task, "value"),
+ )
+ ipw.link(
+ (code_model, "max_wallclock_seconds"),
+ (code_widget.resource_detail.max_wallclock_seconds, "value"),
+ )
+ if isinstance(code_widget, PwCodeResourceSetupWidget):
+ ipw.link(
+ (code_model, "override"),
+ (code_widget.parallelization.override, "value"),
+ )
+ ipw.link(
+ (code_model, "npool"),
+ (code_widget.parallelization.npool, "value"),
+ )
+ code_model.observe(
+ self._on_pw_code_resource_change,
+ ["num_cpus", "num_nodes"],
+ )
+ code_widgets = self.code_widgets_container.children[:-1] # type: ignore
+ self.code_widgets_container.children = [*code_widgets, code_widget]
+ code_model.is_rendered = True
- def set_submission_parameters(self, parameters):
- # backward compatibility for v2023.11
- # which have a separate "resources" section for pw code
- if "resources" in parameters:
- parameters["codes"] = {
- key: {"code": value} for key, value in parameters["codes"].items()
- }
- parameters["codes"]["pw"]["nodes"] = parameters["resources"]["num_machines"]
- parameters["codes"]["pw"]["cpus"] = parameters["resources"][
- "num_mpiprocs_per_machine"
- ]
- parameters["codes"]["pw"]["parallelization"] = {
- "npool": parameters["resources"]["npools"]
- }
- self.set_selected_codes(parameters["codes"])
- # label and description are not stored in the parameters, but in the process directly
- if self.process:
- self.process_label.value = self.process.label
- self.process_description.value = self.process.description
-
- def get_submission_parameters(self):
- """Get the parameters for the submission step."""
- return {
- "codes": self.get_selected_codes(),
- }
-
- def reset(self):
- """Reset the widget to its initial state."""
- with self.hold_trait_notifications():
- self.process = None
- self.input_structure = None
- self.set_selected_codes(DEFAULT_PARAMETERS["codes"])
+ def _update_state(self, _=None):
+ if self.previous_step_state is self.State.FAIL:
+ self.state = self.State.FAIL
+ elif self.previous_step_state is not self.State.SUCCESS:
+ self.state = self.State.INIT
+ elif self._model.process_node is not None:
+ self.state = self.State.SUCCESS
+ elif self._model.is_blocked:
+ self.state = self.State.READY
+ else:
+ self.state = self.state.CONFIGURED
diff --git a/src/aiidalab_qe/app/submission/code/__init__.py b/src/aiidalab_qe/app/submission/code/__init__.py
new file mode 100644
index 000000000..569a461ac
--- /dev/null
+++ b/src/aiidalab_qe/app/submission/code/__init__.py
@@ -0,0 +1,8 @@
+from .model import CodeModel, CodesDict, PluginCodes, PwCodeModel
+
+__all__ = [
+ "CodeModel",
+ "CodesDict",
+ "PluginCodes",
+ "PwCodeModel",
+]
diff --git a/src/aiidalab_qe/app/submission/code/model.py b/src/aiidalab_qe/app/submission/code/model.py
new file mode 100644
index 000000000..39ae7d4d8
--- /dev/null
+++ b/src/aiidalab_qe/app/submission/code/model.py
@@ -0,0 +1,136 @@
+import ipywidgets as ipw
+import traitlets as tl
+
+from aiida import orm
+from aiida.common import NotExistent
+from aiidalab_qe.common.mvc import Model
+from aiidalab_qe.common.widgets import QEAppComputationalResourcesWidget
+
+
+class CodeModel(Model):
+ is_active = tl.Bool(False)
+ options = tl.List(
+ trait=tl.Tuple(tl.Unicode(), tl.Unicode()), # code option (label, uuid)
+ default_value=[],
+ )
+ selected = tl.Unicode(default_value=None, allow_none=True)
+ num_nodes = tl.Int(1)
+ num_cpus = tl.Int(1)
+ ntasks_per_node = tl.Int(1)
+ cpus_per_task = tl.Int(1)
+ max_wallclock_seconds = tl.Int(3600 * 12)
+ allow_hidden_codes = tl.Bool(False)
+ allow_disabled_computers = tl.Bool(False)
+
+ def __init__(
+ self,
+ *,
+ name="",
+ description,
+ default_calc_job_plugin,
+ code_widget_class=QEAppComputationalResourcesWidget,
+ ):
+ self.name = name
+ self.description = description
+ self.default_calc_job_plugin = default_calc_job_plugin
+ self.code_widget_class = code_widget_class
+ self.is_rendered = False
+
+ ipw.dlink(
+ (self, "num_cpus"),
+ (self, "ntasks_per_node"),
+ )
+
+ @property
+ def is_ready(self):
+ return self.is_active and bool(self.selected)
+
+ def activate(self):
+ self.is_active = True
+
+ def deactivate(self):
+ self.is_active = False
+
+ def update(self, user_email):
+ if not self.options:
+ self.options = self._get_codes(user_email)
+ self.selected = self.options[0][1] if self.options else None
+
+ def get_model_state(self) -> dict:
+ return {
+ "code": self.selected,
+ "nodes": self.num_nodes,
+ "cpus": self.num_cpus,
+ "ntasks_per_node": self.ntasks_per_node,
+ "cpus_per_task": self.cpus_per_task,
+ "max_wallclock_seconds": self.max_wallclock_seconds,
+ }
+
+ def set_model_state(self, parameters):
+ self.selected = self._get_uuid(parameters["code"])
+ self.num_nodes = parameters.get("nodes", 1)
+ self.num_cpus = parameters.get("cpus", 1)
+ self.ntasks_per_node = parameters.get("ntasks_per_node", 1)
+ self.cpus_per_task = parameters.get("cpus_per_task", 1)
+ self.max_wallclock_seconds = parameters.get("max_wallclock_seconds", 3600 * 12)
+
+ def _get_uuid(self, identifier):
+ if not self.selected:
+ try:
+ uuid = orm.load_code(identifier).uuid
+ except NotExistent:
+ uuid = None
+ # If the code was imported from another user, it is not usable
+ # in the app and thus will not be considered as an option!
+ self.selected = uuid if uuid in [opt[1] for opt in self.options] else None
+ return self.selected
+
+ def _get_codes(self, user_email):
+ user = orm.User.collection.get(email=user_email)
+
+ filters = (
+ {"attributes.input_plugin": self.default_calc_job_plugin}
+ if self.default_calc_job_plugin
+ else {}
+ )
+
+ codes = (
+ orm.QueryBuilder()
+ .append(
+ orm.Code,
+ filters=filters,
+ )
+ .all(flat=True)
+ )
+
+ return [
+ (self._full_code_label(code), code.uuid)
+ for code in codes
+ if code.computer.is_user_configured(user)
+ and (self.allow_hidden_codes or not code.is_hidden)
+ and (self.allow_disabled_computers or code.computer.is_user_enabled(user))
+ ]
+
+ @staticmethod
+ def _full_code_label(code):
+ return f"{code.label}@{code.computer.label}"
+
+
+class PwCodeModel(CodeModel):
+ override = tl.Bool(False)
+ npool = tl.Int(1)
+
+ def get_model_state(self) -> dict:
+ parameters = super().get_model_state()
+ parameters["parallelization"] = {"npool": self.npool} if self.override else {}
+ return parameters
+
+ def set_model_state(self, parameters):
+ super().set_model_state(parameters)
+ if "parallelization" in parameters and "npool" in parameters["parallelization"]:
+ self.override = True
+ self.npool = parameters["parallelization"].get("npool", 1)
+
+
+CodesDict = dict[str, CodeModel]
+PluginCodes = dict[str, CodesDict]
diff --git a/src/aiidalab_qe/app/submission/model.py b/src/aiidalab_qe/app/submission/model.py
new file mode 100644
index 000000000..90252a4a1
--- /dev/null
+++ b/src/aiidalab_qe/app/submission/model.py
@@ -0,0 +1,415 @@
+from __future__ import annotations
+
+import os
+import typing as t
+from copy import deepcopy
+
+import traitlets as tl
+
+from aiida import orm
+from aiida.engine import ProcessBuilderNamespace, submit
+from aiida.orm.utils.serialize import serialize
+from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS
+from aiidalab_qe.common.mixins import Confirmable, HasInputStructure
+from aiidalab_qe.common.mvc import Model
+from aiidalab_qe.common.widgets import QEAppComputationalResourcesWidget
+from aiidalab_qe.workflows import QeAppWorkChain
+
+from .code import CodeModel, CodesDict
+
+DEFAULT: dict = DEFAULT_PARAMETERS # type: ignore
+
+
+class SubmissionStepModel(
+ Model,
+ HasInputStructure,
+ Confirmable,
+):
+ input_parameters = tl.Dict()
+
+ process_node = tl.Instance(orm.WorkChainNode, allow_none=True)
+ process_label = tl.Unicode("")
+ process_description = tl.Unicode("")
+
+ submission_blocker_messages = tl.Unicode("")
+ submission_warning_messages = tl.Unicode("")
+
+ installing_qe = tl.Bool(False)
+ installing_sssp = tl.Bool(False)
+ qe_installed = tl.Bool(allow_none=True)
+ sssp_installed = tl.Bool(allow_none=True)
+
+ codes = tl.Dict(
+ key_trait=tl.Unicode(), # plugin identifier
+ value_trait=tl.Dict( # plugin codes
+ key_trait=tl.Unicode(), # code name
+ value_trait=tl.Instance(CodeModel), # code metadata
+ ),
+ default_value={},
+ )
+
+ internal_submission_blockers = tl.List(tl.Unicode())
+ external_submission_blockers = tl.List(tl.Unicode())
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self._RUN_ON_LOCALHOST_NUM_SITES_WARN_THRESHOLD = 10
+ self._RUN_ON_LOCALHOST_VOLUME_WARN_THRESHOLD = 1000 # \AA^3
+
+ self._ALERT_MESSAGE = """
+
+ """
+
+ # Used by the code-setup thread to fetch code options
+ # This is necessary to avoid passing the User object
+ # between session in separate threads.
+ self._default_user_email = orm.User.collection.get_default().email
+
+ @property
+ def is_blocked(self):
+ return any(
+ [
+ *self.internal_submission_blockers,
+ *self.external_submission_blockers,
+ ]
+ )
+
+ def confirm(self):
+ if not self.process_node:
+ self._submit()
+ super().confirm()
+ # Once submitted, nothing should unconfirm the model!
+ self.unobserve_all("confirmed")
+
+ def check_resources(self):
+ pw_code = self.get_code("dft", "pw")
+
+ if not self.input_structure or not pw_code.selected:
+ return # No code selected or no structure, so nothing to do
+
+ num_cpus = pw_code.num_cpus * pw_code.num_nodes
+ on_localhost = orm.load_node(pw_code.selected).computer.hostname == "localhost"
+ num_sites = len(self.input_structure.sites)
+ volume = self.input_structure.get_cell_volume()
+
+ try:
+ localhost_cpus = len(os.sched_getaffinity(0))
+ except Exception:
+ # Fallback, in some OS os.sched_getaffinity(0) is not supported
+ # However, not so reliable in containers
+ localhost_cpus = os.cpu_count()
+
+ large_system = (
+ num_sites > self._RUN_ON_LOCALHOST_NUM_SITES_WARN_THRESHOLD
+ or volume > self._RUN_ON_LOCALHOST_VOLUME_WARN_THRESHOLD
+ )
+
+ # Estimated number of CPUs for a run less than 12 hours.
+ estimated_CPUs = self._estimate_min_cpus(num_sites, volume)
+
+ # List of possible suggestions for warnings:
+ suggestions = {
+ "more_resources": f"Increase the resources (total number of CPUs should be equal or more than {min(100,estimated_CPUs)}, if possible) ",
+ "change_configuration": "Review the configuration (e.g. choosing fast protocol - this will affect precision) ",
+ "go_remote": "Select a code that runs on a larger machine",
+ "avoid_overloading": "Reduce the number of CPUs to avoid the overloading of the local machine ",
+ }
+
+ alert_message = ""
+ if large_system and estimated_CPUs > num_cpus:
+ # This part is in common between Warnings 1 (2):
+ # (not) on localhost, big system and few cpus
+ warnings_1_2 = (
+ f"⚠ Warning: The selected structure is large, with {num_sites} atoms "
+ f"and a volume of {int(volume)} Å3, "
+ "making it computationally demanding "
+ "to run at the localhost. Consider the following: "
+ if on_localhost
+ else "to run in a reasonable amount of time. Consider the following: "
+ )
+ # Warning 1: on localhost, big system and few cpus
+ alert_message += (
+ f"{warnings_1_2}"
+ + suggestions["more_resources"]
+ + suggestions["change_configuration"]
+ + "
"
+ if on_localhost
+ else f"{warnings_1_2}"
+ + suggestions["go_remote"]
+ + suggestions["more_resources"]
+ + suggestions["change_configuration"]
+ + "
"
+ )
+ if on_localhost and num_cpus / localhost_cpus > 0.8:
+ # Warning-3: on localhost, more than half of the available cpus
+ alert_message += (
+ "⚠ Warning: the selected pw.x code will run locally, but "
+ f"the number of requested CPUs ({num_cpus}) is larger than the 80% of the available resources ({localhost_cpus}). "
+ "Please be sure that your local "
+ "environment has enough free CPUs for the calculation. Consider the following: "
+ ""
+ + suggestions["avoid_overloading"]
+ + suggestions["go_remote"]
+ + "
"
+ )
+
+ self.submission_warning_messages = (
+ ""
+ if (on_localhost and num_cpus / localhost_cpus) <= 0.8
+ and (not large_system or estimated_CPUs <= num_cpus)
+ else self._ALERT_MESSAGE.format(
+ alert_class="warning",
+ message=alert_message,
+ )
+ )
+
+ def refresh_codes(self):
+ for _, code_model in self.get_code_models(flat=True):
+ code_model.update(self._default_user_email) # type: ignore
+
+ def update_active_codes(self):
+ for name, code_model in self.get_code_models(flat=True):
+ if name != "pw":
+ code_model.deactivate()
+ properties = self._get_properties()
+ for identifier, code_models in self.get_code_models():
+ if identifier in properties:
+ for code_model in code_models.values():
+ code_model.activate()
+
+ def update_process_label(self):
+ if not self.input_structure:
+ self.process_label = ""
+ return
+ structure_label = (
+ self.input_structure.label
+ if len(self.input_structure.label) > 0
+ else self.input_structure.get_formula()
+ )
+ workchain_data = self.input_parameters.get(
+ "workchain",
+ {"properties": []},
+ )
+ properties = [p for p in workchain_data["properties"] if p != "relax"]
+ relax_type = workchain_data.get("relax_type", "none")
+ relax_info = "unrelaxed"
+ if relax_type != "none":
+ relax_info = (
+ "relax: atoms+cell" if "cell" in relax_type else "relax: atoms only"
+ )
+ protocol_and_magnetic_info = f"{workchain_data['protocol']} protocol"
+ if workchain_data["spin_type"] != "none":
+ protocol_and_magnetic_info += ", magnetic"
+ properties_info = f"→ {', '.join(properties)}" if properties else ""
+ label = f"{structure_label} [{relax_info}, {protocol_and_magnetic_info}] {properties_info}".strip()
+ self.process_label = label
+
+ def update_submission_blockers(self):
+ self.internal_submission_blockers = list(self._check_submission_blockers())
+
+ def update_submission_blocker_message(self):
+ blockers = self.internal_submission_blockers + self.external_submission_blockers
+ if any(blockers):
+ fmt_list = "\n".join(f"{item}" for item in sorted(blockers))
+ self.submission_blocker_messages = f"""
+
+
The submission is blocked due to the following reason(s):
+
+
+ """
+ else:
+ self.submission_blocker_messages = ""
+
+ def get_model_state(self) -> dict[str, dict[str, dict]]:
+ parameters: dict = deepcopy(self.input_parameters) # type: ignore
+ parameters["codes"] = self.get_selected_codes()
+ return parameters
+
+ def set_model_state(self, parameters):
+ if "resources" in parameters:
+ parameters["codes"] = {
+ key: {"code": value} for key, value in parameters["codes"].items()
+ }
+ parameters["codes"]["pw"]["nodes"] = parameters["resources"]["num_machines"]
+ parameters["codes"]["pw"]["cpus"] = parameters["resources"][
+ "num_mpiprocs_per_machine"
+ ]
+ parameters["codes"]["pw"]["parallelization"] = {
+ "npool": parameters["resources"]["npools"]
+ }
+ self.set_selected_codes(parameters["codes"])
+ if self.process_node:
+ self.process_label = self.process_node.label
+ self.process_description = self.process_node.description
+ self.loaded_from_process = True
+
+ def add_code(self, identifier, name, code):
+ code.name = name
+ if identifier not in self.codes:
+ self.codes[identifier] = {} # type: ignore
+ self.codes[identifier][name] = code # type: ignore
+
+ def get_code(self, identifier, name) -> CodeModel | None:
+ if identifier in self.codes and name in self.codes[identifier]: # type: ignore
+ return self.codes[identifier][name] # type: ignore
+
+ def get_code_models(
+ self,
+ flat=False,
+ ) -> t.Iterator[tuple[str, CodesDict | CodeModel]]:
+ if flat:
+ for codes in self.codes.values():
+ yield from codes.items()
+ else:
+ yield from self.codes.items()
+
+ def get_selected_codes(self) -> dict[str, dict]:
+ return {
+ name: code_model.get_model_state()
+ for name, code_model in self.get_code_models(flat=True)
+ if code_model.is_ready
+ }
+
+ def set_selected_codes(self, code_data=DEFAULT["codes"]):
+ with self.hold_trait_notifications():
+ for name, code_model in self.get_code_models(flat=True):
+ if name in code_data:
+ code_model.set_model_state(code_data[name])
+
+ def reset(self):
+ with self.hold_trait_notifications():
+ self.input_structure = None
+ self.input_parameters = {}
+ self.process_node = None
+
+ def _submit(self):
+ parameters = self.get_model_state()
+ builder = self._create_builder(parameters)
+
+ with self.hold_trait_notifications():
+ process_node = submit(builder)
+
+ process_node.label = self.process_label
+ process_node.description = self.process_description
+ # since AiiDA data node may exist in the ui_parameters,
+ # we serialize it to yaml
+ process_node.base.extras.set("ui_parameters", serialize(parameters))
+ # store the workchain name in extras, this will help to filter the workchain in the future
+ process_node.base.extras.set("workchain", parameters["workchain"]) # type: ignore
+ process_node.base.extras.set(
+ "structure",
+ self.input_structure.get_formula(),
+ )
+ self.process_node = process_node
+
+ def _get_properties(self) -> list[str]:
+ return self.input_parameters.get("workchain", {}).get("properties", [])
+
+ def _create_builder(self, parameters) -> ProcessBuilderNamespace:
+ builder = QeAppWorkChain.get_builder_from_protocol(
+ structure=self.input_structure,
+ parameters=deepcopy(parameters), # TODO why deepcopy again?
+ )
+
+ codes = parameters["codes"]
+
+ builder.relax.base.pw.metadata.options.resources = {
+ "num_machines": codes.get("pw")["nodes"],
+ "num_mpiprocs_per_machine": codes.get("pw")["ntasks_per_node"],
+ "num_cores_per_mpiproc": codes.get("pw")["cpus_per_task"],
+ }
+ mws = codes.get("pw")["max_wallclock_seconds"]
+ builder.relax.base.pw.metadata.options["max_wallclock_seconds"] = mws
+ parallelization = codes["pw"]["parallelization"]
+ builder.relax.base.pw.parallelization = orm.Dict(dict=parallelization)
+
+ return builder
+
+ def _check_submission_blockers(self):
+ # Do not submit while any of the background setup processes are running.
+ if self.installing_qe or self.installing_sssp:
+ yield "Background setup processes must finish."
+
+ # SSSP library not installed
+ if not self.sssp_installed:
+ yield "The SSSP library is not installed."
+
+ # No pw code selected (this is ignored while the setup process is running).
+ pw_code = self.get_code(identifier="dft", name="pw")
+ if pw_code and not pw_code.selected and not self.installing_qe:
+ yield ("No pw code selected")
+
+ # code related to the selected property is not installed
+ properties = self._get_properties()
+ message = "Calculating the {property} property requires code {code} to be set."
+ for identifier, codes in self.get_code_models():
+ if identifier in properties:
+ for code in codes.values():
+ if not code.is_ready:
+ yield message.format(property=identifier, code=code.description)
+
+ # check if the QEAppComputationalResourcesWidget is used
+ for name, code in self.get_code_models(flat=True):
+ # skip if the code is not displayed, convenient for the plugin developer
+ if not code.is_ready:
+ continue
+ if not issubclass(
+ code.code_widget_class, QEAppComputationalResourcesWidget
+ ):
+ yield (
+ f"Error: hi, plugin developer, please use the QEAppComputationalResourcesWidget from aiidalab_qe.common.widgets for code {name}."
+ )
+
+ def _estimate_min_cpus(
+ self,
+ n,
+ v,
+ n0=9,
+ v0=117,
+ num_cpus0=4,
+ t0=129.6,
+ tmax=12 * 60 * 60,
+ scf_cycles=5,
+ ):
+ """Estimate the minimum number of CPUs required to
+ complete a task within a given time limit.
+
+ Parameters
+ ----------
+ `n` : `int`
+ The number of atoms in the system.
+ `v` : `float`
+ The volume of the system.
+ `n0` : `int`, optional
+ Reference number of atoms. Default is 9.
+ `v0` : `float`, optional
+ Reference volume. Default is 117.
+ `num_cpus0` : `int`, optional
+ Reference number of CPUs. Default is 4.
+ `t0` : `float`, optional
+ Reference time. Default is 129.6.
+ `tmax` : `float`, optional
+ Maximum time limit. Default is 12 hours.
+ `scf_cycles` : `int`, optional
+ Reference number of SCF cycles in a relaxation. Default is 5.
+
+ Returns
+ -------
+ `int`
+ The estimated minimum number of CPUs required.
+ """
+ import numpy as np
+
+ return int(
+ np.ceil(
+ scf_cycles * num_cpus0 * (n / n0) ** 3 * (v / v0) ** 1.5 * t0 / tmax
+ )
+ )
diff --git a/src/aiidalab_qe/app/utils/__init__.py b/src/aiidalab_qe/app/utils/__init__.py
index a62d8c148..ca61694f6 100644
--- a/src/aiidalab_qe/app/utils/__init__.py
+++ b/src/aiidalab_qe/app/utils/__init__.py
@@ -15,12 +15,14 @@ def print_error(entry_point, e):
# load entry points
def get_entries(entry_point_name="aiidalab_qe.properties"):
- from importlib.metadata import entry_points
+ from importlib_metadata import entry_points
entries = {}
- for entry_point in entry_points().get(entry_point_name, []):
+ for entry_point in entry_points(group=entry_point_name):
try:
# Attempt to load the entry point
+ if entry_point.name in entries:
+ continue
loaded_entry_point = entry_point.load()
entries[entry_point.name] = loaded_entry_point
except Exception as e:
diff --git a/src/aiidalab_qe/app/utils/search_jobs.py b/src/aiidalab_qe/app/utils/search_jobs.py
index c5e0e6f45..efbe9e40d 100644
--- a/src/aiidalab_qe/app/utils/search_jobs.py
+++ b/src/aiidalab_qe/app/utils/search_jobs.py
@@ -1,18 +1,20 @@
+import ipywidgets as ipw
+import pandas as pd
+from IPython.display import display
+
+from aiida.orm import QueryBuilder
+
+
class QueryInterface:
def __init__(self):
pass
def setup_table(self):
- import ipywidgets as ipw
-
self.df = self.load_data()
self.table = ipw.HTML()
self.setup_widgets()
def load_data(self):
- import pandas as pd
-
- from aiida.orm import QueryBuilder
from aiidalab_qe.workflows import QeAppWorkChain
projections = [
@@ -72,8 +74,6 @@ def load_data(self):
]
def setup_widgets(self):
- import ipywidgets as ipw
-
self.css_style = """