From 9b965f8c66a76dbd314602eaccfcac6172c03946 Mon Sep 17 00:00:00 2001 From: "Peter N. O. Gillespie" Date: Tue, 21 Nov 2023 17:22:20 +0000 Subject: [PATCH 01/11] XAS: Add XAS Plugin Adds a working version of a plugin to calculate XANES spectra using the XspectraCrystalWorkChain of AiiDA-QE This commit requires a locally-situated copy of the core-hole pseudopotentials required for XSpectra to function, which is obtained from https://github.com/PNOGillespie/Core_Level_Spectra_Pseudos and downloaded and installed upon first activation of AiiDALab-QE --- setup.cfg | 1 + src/aiidalab_qe/plugins/xas/__init__.py | 25 ++ src/aiidalab_qe/plugins/xas/result.py | 282 +++++++++++++++++++++++ src/aiidalab_qe/plugins/xas/setting.py | 252 ++++++++++++++++++++ src/aiidalab_qe/plugins/xas/workchain.py | 246 ++++++++++++++++++++ 5 files changed, 806 insertions(+) create mode 100644 src/aiidalab_qe/plugins/xas/__init__.py create mode 100644 src/aiidalab_qe/plugins/xas/result.py create mode 100644 src/aiidalab_qe/plugins/xas/setting.py create mode 100644 src/aiidalab_qe/plugins/xas/workchain.py diff --git a/setup.cfg b/setup.cfg index 4f7e1e685..f991aead6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -57,6 +57,7 @@ aiidalab_qe.properties = bands = aiidalab_qe.plugins.bands:bands pdos = aiidalab_qe.plugins.pdos:pdos electronic_structure = aiidalab_qe.plugins.electronic_structure:electronic_structure + xas = aiidalab_qe.plugins.xas:xas [aiidalab] title = Quantum ESPRESSO diff --git a/src/aiidalab_qe/plugins/xas/__init__.py b/src/aiidalab_qe/plugins/xas/__init__.py new file mode 100644 index 000000000..41be42a2b --- /dev/null +++ b/src/aiidalab_qe/plugins/xas/__init__.py @@ -0,0 +1,25 @@ +from aiidalab_widgets_base import ComputationalResourcesWidget + +from aiidalab_qe.common.panel import OutlinePanel + +from .result import Result +from .setting import Setting +from .workchain import workchain_and_builder + + +class XasOutline(OutlinePanel): + title = "X-ray absorption spectroscopy (XAS)" + help = """""" + + +xs_code = ComputationalResourcesWidget( + description="xspectra.x", default_calc_job_plugin="quantumespresso.xspectra" +) + +xas = { + "outline": XasOutline, + "code": {"xspectra": xs_code}, + "setting": Setting, + "result": Result, + "workchain": workchain_and_builder, +} diff --git a/src/aiidalab_qe/plugins/xas/result.py b/src/aiidalab_qe/plugins/xas/result.py new file mode 100644 index 000000000..ad6149e20 --- /dev/null +++ b/src/aiidalab_qe/plugins/xas/result.py @@ -0,0 +1,282 @@ +"""XAS results view widgets + +""" +import ipywidgets as ipw +import numpy as np +from scipy.interpolate import make_interp_spline + +from aiidalab_qe.common.panel import ResultPanel + + +def export_xas_data(outputs): + if "final_spectra" in outputs.xas: + final_spectra = outputs.xas.final_spectra + symmetry_analysis_data = outputs.xas.symmetry_analysis_data.get_dict() + equivalent_sites_data = symmetry_analysis_data["equivalent_sites_data"] + + return ( + final_spectra, + equivalent_sites_data, + ) + else: + return None + + +def broaden_xas( + input_array, variable=False, gamma_hole=0.01, gamma_max=5, center_energy=15 +): + """Take an input spectrum and return a broadened spectrum as output using either a constant or variable parameter. + + :param input_array: The 2D array of x/y values to be broadened. Should be plotted with + little or no broadening before using the function. + :param gamma_hole: The broadening parameter for the Lorenzian broadening function. In constant mode (variable=False), + this value is applied to the entire spectrum. In variable mode (variable=True), this value defines + the starting broadening parameter of the arctangent function. Refers to the natural linewidth of + the element/XAS edge combination in question and (for elements Z > 10) should be based on reference + values from X-ray spectroscopy. + :param variable: Request a variable-energy broadening of the spectrum instead of the defaultconstant broadening. + Uses the functional form defined in Calandra and Bunau, PRB, 87, 205105 (2013). + :param gamma_max: The maximum lorenzian broadening to be applied in variable energy broadening mode. Refers to the + broadening effects at infinite energy above the main edge. + :param center_energy: The inflection point of the variable broadening function. Does not relate to experimental data + and must be tuned manually. + """ + + if variable: + if not all([gamma_hole, gamma_max, center_energy]): + missing = [ + i[0] + for i in zip( + ["gamma_hole", "gamma_max", "center_energy"], + [gamma_hole, gamma_max, center_energy], + ) + if i[1] is None + ] + raise ValueError( + f"The following variables were not defined {missing} and are required for variable-energy broadening" + ) + + x_vals = input_array[:, 0] + y_vals = input_array[:, 1] + # y_vals_norm = np.array(y_vals/np.trapz(y_vals, x_vals)) + + lorenz_y = np.zeros(len(x_vals)) + + if variable: + for x, y in zip(x_vals, y_vals): + if x < 0: # the function is bounded between gamma_hole and gamma_max + gamma_var = gamma_hole + else: + e = x / center_energy + + gamma_var = gamma_hole + gamma_max * ( + 0.5 + np.arctan((e - 1) / (e**2)) / np.pi + ) + + if x <= 1.0e-6: # do this to avoid trying to broaden values close to 0 + lorenz_y = y + else: + lorenz_y += ( + gamma_var + / 2.0 + / np.pi + / ((x_vals - x) ** 2 + 0.25 * gamma_var**2) + * y + ) + else: + for x, y in zip(x_vals, y_vals): + lorenz_y += ( + gamma_hole + / 2.0 + / np.pi + / ((x_vals - x) ** 2 + 0.25 * gamma_hole**2) + * y + ) + + return np.column_stack((x_vals, lorenz_y)) + + +class Result(ResultPanel): + title = "XAS" + workchain_labels = ["xas"] + + def __init__(self, node=None, **kwargs): + super().__init__(node=node, identifier="xas", **kwargs) + + def _update_view(self): + import plotly.graph_objects as go + + gamma_select_prompt = ipw.HTML( + """ +
+ Select parameters for spectrum broadening
""" + ) + + # PNOG: If someone knows a way to format certain words differently in HTML without causing a line-break, hit me up. + # For now, (17/10/23) we'll just have the function terms be in italics. + # Alternatively, if it's possible to format mathematical terms in HTML without a line-break, let me know + variable_broad_select_help = ipw.HTML( + """ +
+ Broadening parameters: + +

Γhole - Defines a constant Lorenzian broadening width for the whole spectrum. In "variable" mode, defines the initial broadening width of the ArcTangent function.

+

Γmax - Maximum Lorenzian broadening parameter at infinte energy in "variable" mode.

+

Emax - Defines the inflection point of the variable-energy broadening function.

+
+
+ Note that setting Γhole to 0 eV will simply plot the raw spectrum. +
+ """ + ) + spectrum_select_prompt = ipw.HTML( + """ +
+ Select spectrum to plot
""" + ) + final_spectra, _ = export_xas_data(self.outputs) + + spectrum_select_options = [key.split("_")[0] for key in final_spectra.keys()] + + spectrum_select = ipw.Dropdown( + description="", + disabled=False, + value=spectrum_select_options[0], + options=spectrum_select_options, + layout=ipw.Layout(width="20%"), + ) + + variable_broad_select = ipw.Checkbox( + value=False, + disabled=False, + description="Use variable energy broadening.", + style={"description_width": "initial", "opacity": 0.5}, + ) + + gamma_hole_select = ipw.FloatSlider( + value=0.0, + min=0.0, + max=5, + step=0.1, + description="$\Gamma_{hole}$", # noqa: W605 + disabled=False, + continuous_update=False, + orientation="horizontal", + readout=True, + ) + + gamma_max_select = ipw.FloatSlider( + value=5.0, + min=2.0, + max=10, + step=0.5, + continuous_update=False, + description="$\Gamma_{max}$", # noqa: W605 + disabled=True, + orientation="horizontal", + readout=True, + ) + + center_e_select = ipw.FloatSlider( + value=15.0, + min=5, + max=30, + step=0.5, + continuous_update=False, + description="$E_{center}$", + disabled=True, + orientation="horizontal", + readout=True, + ) + + # # get data + # # init figure + g = go.FigureWidget( + layout=go.Layout( + title=dict(text="XAS"), + barmode="overlay", + ) + ) + + g.layout.xaxis.title = "Relative Photon Energy (eV)" + + chosen_spectrum = spectrum_select.value + chosen_spectrum_label = f"{chosen_spectrum}_xas" + spectra = final_spectra[chosen_spectrum_label] + + # for spectrum_label, data in spectra.items(): + element = chosen_spectrum_label.split("_")[0] + raw_spectrum = np.column_stack((spectra.get_x()[1], spectra.get_y()[0][1])) + x = raw_spectrum[:, 0] + y = raw_spectrum[:, 1] + spline = make_interp_spline(x, y) + norm_y = spline(x) / np.trapz(spline(x), x) + g.add_scatter(x=x, y=norm_y, name=element) + + def response(change): + chosen_spectrum = spectrum_select.value + chosen_spectrum_label = f"{chosen_spectrum}_xas" + spectra = final_spectra[chosen_spectrum_label] + raw_spectrum = np.column_stack((spectra.get_x()[1], spectra.get_y()[0][1])) + x = raw_spectrum[:, 0] + y = raw_spectrum[:, 1] + if not variable_broad_select: + gamma_max_select.disabled = True + center_e_select.disabled = True + else: + gamma_max_select.disabled = False + center_e_select.disabled = False + + if gamma_hole_select.value == 0.0: + x = raw_spectrum[:, 0] + y = raw_spectrum[:, 1] + else: + broad_spectrum = broaden_xas( + raw_spectrum, + gamma_hole=gamma_hole_select.value, + gamma_max=gamma_max_select.value, + center_energy=center_e_select.value, + variable=variable_broad_select.value, + ) + x = broad_spectrum[:, 0] + y = broad_spectrum[:, 1] + + spline = make_interp_spline(x, y) + norm_y = spline(x) / np.trapz(spline(x), x) + + g.update(data=[{"x": x, "y": norm_y, "name": chosen_spectrum}]) + + spectrum_select.observe(response, names="value") + gamma_hole_select.observe(response, names="value") + gamma_max_select.observe(response, names="value") + center_e_select.observe(response, names="value") + variable_broad_select.observe(response, names="value") + + self.children = [ + ipw.HBox( + [ + ipw.VBox( + [ + spectrum_select_prompt, + spectrum_select, + gamma_select_prompt, + gamma_hole_select, + gamma_max_select, + center_e_select, + ], + layout=ipw.Layout(width="40%"), + ), + ipw.VBox( + [ + variable_broad_select, + variable_broad_select_help, + # variable_broad_select_prompt, + ], + layout=ipw.Layout(width="60%"), + ), + # ipw.VBox([ + # ], layout=ipw.Layout(width="60%")), + ] + ), + g, + ] diff --git a/src/aiidalab_qe/plugins/xas/setting.py b/src/aiidalab_qe/plugins/xas/setting.py new file mode 100644 index 000000000..f2f41fca1 --- /dev/null +++ b/src/aiidalab_qe/plugins/xas/setting.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +"""Panel for XAS plugin. + +""" +import ipywidgets as ipw +import traitlets as tl +from aiida import orm +from aiida.orm import StructureData + +from aiidalab_qe.common.panel import Panel + +xch_elements = ["Li", "C"] +dir_header = "cls_pseudos" +functionals = ["pbe"] +core_wfc_dir = "core_wfc_data" +gipaw_dir = "gipaw_pseudos" +ch_pseudo_dir = "ch_pseudos/star1s" + + +class Setting(Panel): + title = "XAS Settings" + identifier = "xas" + input_structure = tl.Instance(StructureData, allow_none=True) + protocol = tl.Unicode(allow_none=True) + + element_selection_title = ipw.HTML( + """
+

Element and Core-Hole Treatment Setting.

""" + ) + + # TODO: The element selection should lock the "Confirm" button if no elements have been + # selected for XAS calculation. + + element_selection_help = ipw.HTML( + """
+ To select elements for calculation of K-edge spectra:
+ (1) Tick the checkbox for each element symbol to select the element for calculation.
+ (2) Select the core-hole treatment scheme from the dropdown box.
+
+ There are three supported options for core-hole treatment:
+ - FCH: Remove one electron from the system (any occupations scheme).
+ - XCH (Smearing): places the excited electron into the conduction band (smeared occupations).
+ - XCH (Fixed): places the excited electron into the conduction band (fixed occupations).
+
+ For XAS calculations of most elements, the FCH treatment is recommended, however in some cases the XCH treatment should be used instead.
+ The recommended setting will be shown for each available element. + Note that only elements for which core-hole pseudopotential sets are available + will be shown.
+
""" + ) + # I will leave these objects here for now (15/11/23), but since the calculation of molecular + # systems is not really supported (neither in terms of XAS nor the main App itself) we should + # not present this option that essentially does nothing. + structure_title = ipw.HTML( + """
+

Structure

""" + ) + structure_help = ipw.HTML( + """
+ Below you can indicate if the material should be treated as a molecule + or a crystal. +
""" + ) + supercell_title = ipw.HTML( + """
+

Cell size

""" + ) + supercell_help = ipw.HTML( + """
+ Define the minimum cell length in angstrom for the resulting supercell, and thus all output + structures. The default value of 8.0 angstrom will be used + if no input is given. Setting this value to 0.0 will + instruct the CF to not scale up the input structure. +
""" + ) + + def __init__(self, **kwargs): + self.gipaw_pseudo_group = orm.load_group("cls_pseudos/pbe/gipaw_pseudos") + self.core_hole_pseudos_group = orm.load_group( + "cls_pseudos/pbe/ch_pseudos/star1s" + ) + self.core_wfc_group = orm.load_group("cls_pseudos/pbe/core_wfc_data") + + self.element_and_ch_treatment = ipw.VBox(layout=ipw.Layout(width="100%")) + + self.structure_type = ipw.ToggleButtons( + options=[ + ("Molecule", "molecule"), + ("Crystal", "crystal"), + ], + value="crystal", + ) + self.supercell_min_parameter = ipw.FloatText( + value=8.0, + description="The minimum cell length (Å):", + disabled=False, + style={"description_width": "initial"}, + ) + + self.children = [ + # self.structure_title, + # self.structure_help, + # ipw.HBox( + # [self.structure_type], + # ), + self.element_selection_title, + self.element_selection_help, + ipw.HBox([self.element_and_ch_treatment], layout=ipw.Layout(width="95%")), + self.supercell_title, + self.supercell_help, + ipw.HBox( + [self.supercell_min_parameter], + ), + ] + + super().__init__(**kwargs) + + def get_panel_value(self): + """Return a dictionary with the input parameters for the plugin.""" + + elements_list = [] + core_hole_treatments = {} + for entry in self.element_and_ch_treatment.children: + if entry.children[0].value is True: + element = entry.children[0].description + ch_treatment = entry.children[1].value + elements_list.append(element) + core_hole_treatments[element] = ch_treatment + + parameters = { + "core_hole_treatments": core_hole_treatments, + "elements_list": elements_list, + "structure_type": self.structure_type.value, + "gipaw_pseudo_group": self.gipaw_pseudo_group.label, + "ch_pseudo_group": self.core_hole_pseudos_group.label, + "core_wfc_data_group": self.core_wfc_group.label, + } + return parameters + + def set_panel_value(self, input_dict): + """Load a dictionary with the input parameters for the plugin.""" + + ch_group = self.core_hole_pseudos_group + structure = self.input_structure + available_elements = [node.label.split(".")[0] for node in ch_group.nodes] + elements_to_select = sorted( + [ + kind.symbol + for kind in structure.kinds + if kind.symbol in available_elements + ] + ) + + element_and_ch_treatment = {} + for element in elements_to_select: + if element in xch_elements: + element_and_ch_treatment[element] = "xch_smear" + else: + element_and_ch_treatment[element] = "full" + + self.element_and_ch_treatment = element_and_ch_treatment + self.structure_type.value = input_dict.get("structure_type", "crystal") + + @tl.observe("input_structure") + def _update_structure(self, _=None): + self._update_element_select_panel() + + def _update_element_select_panel(self): + if self.input_structure is None: + return + + starting_treatment_mapping = {"FCH": "full", "XCH": "xch_smear"} + ch_treatment_options = [ + ("FCH", "full"), + ("XCH (Smearing)", "xch_smear"), + ("XCH (Fixed)", "xch_fixed"), + ] + ch_group = self.core_hole_pseudos_group + structure = self.input_structure + # (21/11/23) Temporarilly block Li from the list until issues with the + # current pseudo are resolved: + available_elements = [ + node.label.split(".")[0] + for node in ch_group.nodes + if node.label.split(".")[0] != "Li" + ] + elements_to_select = sorted( + [ + kind.symbol + for kind in structure.kinds + if kind.symbol in available_elements + ] + ) + treatment_options = () + + for element in elements_to_select: + if element in xch_elements: + recommended_treatment = "XCH" + else: + recommended_treatment = "FCH" + + treatment_options += ( + ipw.HBox( + [ + ipw.Checkbox( + description=element, + value=False, + disabled=False, + style={"description_width": "initial"}, + layout=ipw.Layout(width="7%"), + ), + ipw.Dropdown( + options=ch_treatment_options, + value=starting_treatment_mapping[recommended_treatment], + disabled=False, + layout=ipw.Layout(width="15%"), + ), + ipw.HTML( + f"Recommended treatment: {recommended_treatment} (PBE Core-Hole Pseudopotential)", + layout=ipw.Layout(width="78%"), + ), + ], + layout=ipw.Layout( + width="100%", + ), + ), + ) + + self.element_and_ch_treatment.children = treatment_options + + # TODO: Maybe find a way to cut back the nesting by 1 at least, since I don't think that there should be this much + + # For reference: + # This is the whole widget: + # print(f"{self.element_and_ch_treatment}\n") + + # This is the tuple of selected element and core-hole treatment: + # print(f"{self.element_and_ch_treatment.children[0]}\n") + + # This is the checkbox for the element, giving element name and whether to add it to the elements list + # print(f"{self.element_and_ch_treatment.children[0].children[0]}\n") + # print(f"{self.element_and_ch_treatment.children[0].children[0].value}\n") + # print(f"{self.element_and_ch_treatment.children[0].children[0].description}\n") + + # This is the dropdown for the core-hole treatment option: + # print(f"{self.element_and_ch_treatment.children[0].children[1]}\n") + # print(f"{self.element_and_ch_treatment.children[0].children[1].value}\n") + + def reset(self): + """Reset the panel to its initial state.""" + self.input_structure = None + self.structure_type.value = "crystal" diff --git a/src/aiidalab_qe/plugins/xas/workchain.py b/src/aiidalab_qe/plugins/xas/workchain.py new file mode 100644 index 000000000..3d299478f --- /dev/null +++ b/src/aiidalab_qe/plugins/xas/workchain.py @@ -0,0 +1,246 @@ +import os +import tarfile + +import requests +from aiida import orm +from aiida.plugins import WorkflowFactory +from aiida_quantumespresso.common.types import ElectronicType, SpinType + +XspectraCrystalWorkChain = WorkflowFactory("quantumespresso.xspectra.crystal") + + +def load_or_create_group(group_label_string): + """Check for the existence of a group matching a label, and create one if not found.""" + + from aiida.orm import UpfFamily + + try: + group = orm.load_group(group_label_string) + except BaseException: + group = UpfFamily(group_label_string) + group.store() + + return group + + +def check_ch_pseudos_for_elements(group_label): + """Check a set of core-hole pseudos for which elements are available.""" + + from aiida import orm + + elements_list = [] + group = orm.load_group() + for node in group.nodes: + element = node.label.split(".")[0] + elements_list.append(element) + + return elements_list + + +base_url = "https://github.com/PNOGillespie/Core_Level_Spectra_Pseudos/raw/main" +head_path = "/home/jovyan/Utils/QE/Pseudos" +dir_header = "cls_pseudos" +functionals = ["pbe"] +core_wfc_dir = "core_wfc_data" +gipaw_dir = "gipaw_pseudos" +ch_pseudo_dir = "ch_pseudos/star1s" + +url = f"{base_url}" +for func in functionals: + dir = f"{head_path}/{dir_header}/{func}" + os.makedirs(dir, exist_ok=True) + archive_filename = f"{func}_ch_pseudos.tgz" + archive_found = False + for entry in os.listdir(dir): + if entry == archive_filename: + archive_found = True + if not archive_found: + remote_archive_filename = f"{base_url}/{func}/{archive_filename}" + local_archive_filename = f"{dir}/{archive_filename}" + + env = os.environ.copy() + env["PATH"] = f"{env['PATH']}:{dir}" + + response = requests.get(remote_archive_filename, timeout=30) + response.raise_for_status() + with open(local_archive_filename, "wb") as handle: + handle.write(response.content) + handle.flush() + + with tarfile.open(local_archive_filename, "r:gz") as tarfil: + tarfil.extractall(dir) + +cls_group_head = load_or_create_group(dir_header) +for func in functionals: + func_group = load_or_create_group(f"{dir_header}/{func}") + # Strictly speaking, this one won't have UPF data in it, but it *is* related + # to the other UPF data anyway. + core_wfc_group = load_or_create_group(f"{dir_header}/{func}/{core_wfc_dir}") + core_wfc_node_labels = [node.label for node in core_wfc_group.nodes] + core_wfc_nodes = [] + core_wfc_path = f"{head_path}/{core_wfc_group.label}" + for file in os.listdir(core_wfc_path): + if file not in core_wfc_node_labels: + new_singlefile = orm.SinglefileData( + f"{core_wfc_path}/{file}", filename="stdout" + ) + new_singlefile.label = file + new_singlefile.store() + core_wfc_nodes.append(new_singlefile) + if len(core_wfc_nodes) > 0: + core_wfc_group.add_nodes(core_wfc_nodes) + + gipaw_group = load_or_create_group(f"{dir_header}/{func}/{gipaw_dir}") + gipaw_node_labels = [node.label for node in gipaw_group.nodes] + gipaw_nodes = [] + gipaw_path = f"{head_path}/{gipaw_group.label}" + for file in os.listdir(gipaw_path): + if file not in gipaw_node_labels: + new_upf = orm.UpfData(f"{gipaw_path}/{file}", filename=file) + new_upf.label = file + new_upf.store() + gipaw_nodes.append(new_upf) + if len(gipaw_nodes) > 0: + gipaw_group.add_nodes(gipaw_nodes) + + ch_group = load_or_create_group(f"{dir_header}/{func}/{ch_pseudo_dir}") + ch_node_labels = [node.label for node in ch_group.nodes] + ch_nodes = [] + ch_path = f"{head_path}/{ch_group.label}" + for file in os.listdir(ch_path): + if file not in ch_node_labels: + new_upf = orm.UpfData(f"{ch_path}/{file}", filename=file) + new_upf.label = file + new_upf.store() + ch_nodes.append(new_upf) + if len(ch_nodes) > 0: + ch_group.add_nodes(ch_nodes) + + +def get_builder(codes, structure, parameters, **kwargs): + from copy import deepcopy + + protocol = parameters["workchain"]["protocol"] + xas_parameters = parameters["xas"] + gipaw_pseudo_group = orm.load_group(xas_parameters["gipaw_pseudo_group"]) + ch_pseudo_group = orm.load_group(xas_parameters["ch_pseudo_group"]) + core_wfc_data_group = orm.load_group(xas_parameters["core_wfc_data_group"]) + # set pseudo for element + pseudos = {} + core_wfc_data = {} + core_hole_treatments = xas_parameters["core_hole_treatments"] + elements_list = xas_parameters["elements_list"] + for element in elements_list: + gipaw_pseudo = [ + gipaw_upf + for gipaw_upf in gipaw_pseudo_group.nodes + if gipaw_upf.element == element + ][0] + ch_pseudo = [ + ch_upf for ch_upf in ch_pseudo_group.nodes if ch_upf.element == element + ][0] + core_wfc_node = [ + sf for sf in core_wfc_data_group.nodes if sf.label.split(".")[0] == element + ][0] + pseudos[element] = {"gipaw": gipaw_pseudo, "core_hole": ch_pseudo} + core_wfc_data[element] = core_wfc_node + + # TODO should we override the cutoff_wfc, cutoff_rho by the new pseudo? + # In principle we should, if we know what that value is, but that would + # require testing them first... + + # (13/10/23) I'm keeping the part about molecules in for future reference, + # but we need to establish the protocol & backend code for XAS of molecules + # before thinking about a workflow. + is_molecule_input = ( + True if xas_parameters.get("structure_type") == "molecule" else False + ) + + # core_hole_treatment = xas_parameters["core_hole_treatment"] + # core_hole_treatments = {element: core_hole_treatment for element in elements_list} + + structure_preparation_settings = { + # "supercell_min_parameter": Float(supercell_min_parameter_map[protocol]), + "is_molecule_input": orm.Bool(is_molecule_input), + } + spglib_settings = orm.Dict({"symprec": 1.0e-3}) + + pw_code = codes["pw"] + xs_code = codes["xspectra"] + overrides = { + "core": { + "scf": deepcopy(parameters["advanced"]), + # PG: Here, we set a "variable" broadening scheme, which actually defines a constant broadening + # The reason for this is that in "gamma_mode = constant", the Lorenzian broadening parameter + # is defined by "xgamma" (in "PLOT"), but this parameter *also* controls the broadening value + # used in the Lanczos algorithm to enhance the convergence rate. In order to give the user a + # final spectrum with minimal broadening, we use "gamma_mode = variable", which uses a different + # parameter set ("gamma_energy(1-2)", "gamma_value(1-2)") and thus allows us to decouple spectrum + # broadening from Lanczos broadening and avoid having to re-plot the final spectrum. + "xs_prod": { + "xspectra": { + "parameters": { + "PLOT": { + "gamma_mode": "variable", + "gamma_energy(1)": 0, + "gamma_energy(2)": 1, + "gamma_value(1)": 0.1, + "gamma_value(2)": 0.1, + } + } + } + }, + } + } + # TODO: We need to ensure that the family used for selecting the pseudopotentials in + # the CrystalWorkChain is set to the same as the functional needed for the + # core-hole/gipaw pseudo pair. In the future, this should check which one it is + # and set the correct value if it isn't already. + # For now, I've tried to override the pseudo family automatically using the example below, + # but it seems that the override is being *overriden* by some other part of the App + # chosen_pseudo_family = overrides["core"]["scf"]["pseudo_family"] + # if "/PBE/" not in chosen_pseudo_family: + # if chosen_pseudo_family.split("/")[0] == "PseudoDojo": + # overrides["core"]["scf"]["pseudo_family"] = "PseudoDojo/0.4/PBE/SR/standard/upf" + # elif chosen_pseudo_family.split("/")[0] == "SSSP": + # overrides["core"]["scf"]["pseudo_family"] = "SSSP/1.2/PBE/efficiency" + + builder = XspectraCrystalWorkChain.get_builder_from_protocol( + pw_code=pw_code, + xs_code=xs_code, + structure=structure, + protocol=protocol, + pseudos=pseudos, + elements_list=elements_list, + core_hole_treatments=core_hole_treatments, + core_wfc_data=core_wfc_data, + structure_preparation_settings=structure_preparation_settings, + electronic_type=ElectronicType(parameters["workchain"]["electronic_type"]), + spin_type=SpinType(parameters["workchain"]["spin_type"]), + # TODO: We will need to merge the changes in AiiDA-QE PR#969 in order + # to better handle magnetic and Hubbard data. For now, we can probably + # leave it as it is. + initial_magnetic_moments=parameters["advanced"]["initial_magnetic_moments"], + overrides=overrides, + **kwargs, + ) + builder.pop("relax") + builder.pop("clean_workdir", None) + builder.spglib_settings = spglib_settings + # there is a bug in aiida-quantumespresso xps, that one can not set the kpoints + # this is fxied in a PR, but we need to wait for the next release. + # we set a large kpoints_distance value to set the kpoints to 1x1x1 + if is_molecule_input: + # kpoints = KpointsData() + # kpoints.set_kpoints_mesh([1, 1, 1]) + # parameters["advanced"]["kpoints"] = kpoints + # builder.ch_scf.kpoints_distance = Float(5) + pass + return builder + + +workchain_and_builder = { + "workchain": XspectraCrystalWorkChain, + "exclude": ("clean_workdir", "structure", "relax"), + "get_builder": get_builder, +} From 1d1f3e70a47dfd2caac2f0e6a0eae087c543bad1 Mon Sep 17 00:00:00 2001 From: "Peter N. O. Gillespie" Date: Mon, 4 Dec 2023 09:45:12 +0000 Subject: [PATCH 02/11] XAS: Refactor CH Pseudo System Refactors the system for importing core-hole pseudopotentials from using groups to using a `yaml` file to define which pseudos should be present in the ch pseudos archive. Greatly simplifies the logic for checking for the presence of pseudopotentials and populating lists of available pseudos. Yaml file `pseudo_toc` is obtained from the Core_Level_Spectra_Pseudos archive (https://github.com/PNOGillespie/Core_Level_Spectra_Pseudos) and must be updated on both the archive and the plugin when new pseudos are available on the CLS archive. Also adds a step in `workchain.py` to check for mismatches in which pseudos should be present and re-download the archive file if there is a mismatch. Added in order to check for inconsistencies and to automatically update the archive if `pseudo_toc.yaml` is updated with new pseudos. --- src/aiidalab_qe/plugins/xas/__init__.py | 6 + src/aiidalab_qe/plugins/xas/pseudo_toc.yaml | 26 +++ src/aiidalab_qe/plugins/xas/setting.py | 42 ++--- src/aiidalab_qe/plugins/xas/workchain.py | 195 +++++++++----------- 4 files changed, 132 insertions(+), 137 deletions(-) create mode 100644 src/aiidalab_qe/plugins/xas/pseudo_toc.yaml diff --git a/src/aiidalab_qe/plugins/xas/__init__.py b/src/aiidalab_qe/plugins/xas/__init__.py index 41be42a2b..1ca6f4473 100644 --- a/src/aiidalab_qe/plugins/xas/__init__.py +++ b/src/aiidalab_qe/plugins/xas/__init__.py @@ -1,11 +1,17 @@ +from importlib import resources + +import yaml from aiidalab_widgets_base import ComputationalResourcesWidget from aiidalab_qe.common.panel import OutlinePanel +from aiidalab_qe.plugins import xas as xas_folder from .result import Result from .setting import Setting from .workchain import workchain_and_builder +PSEUDO_TOC = yaml.safe_load(resources.read_text(xas_folder, "pseudo_toc.yaml")) + class XasOutline(OutlinePanel): title = "X-ray absorption spectroscopy (XAS)" diff --git a/src/aiidalab_qe/plugins/xas/pseudo_toc.yaml b/src/aiidalab_qe/plugins/xas/pseudo_toc.yaml new file mode 100644 index 000000000..8c3299225 --- /dev/null +++ b/src/aiidalab_qe/plugins/xas/pseudo_toc.yaml @@ -0,0 +1,26 @@ +--- +xas_xch_elements: [C, Li] +pseudos: + pbe: + gipaw_pseudos: + C: C.pbe-n-kjgipaw_psl.1.0.0.UPF + Cu: Cu.pbe-n-van_gipaw.UPF + F: F.pbe-gipaw_kj_no_hole.UPF + Li: Li.pbe-s-rrkjus-gipaw.UPF + O: O.pbe-n-kjpaw_gipaw.UPF + Si: Si.pbe-van_gipaw.UPF + core_wavefunction_data: + C: C.pbe-n-kjgipaw_psl.1.0.0.dat + Cu: Cu.pbe-n-van_gipaw.dat + F: F.pbe-gipaw_kj_no_hole.dat + Li: Li.pbe-s-rrkjus-gipaw.dat + O: O.pbe-n-kjpaw_gipaw.dat + Si: Si.pbe-van_gipaw.dat + core_hole_pseudos: + 1s: + C: C.star1s.pbe-n-kjgipaw_psl.1.0.0.UPF + Cu: Cu.star1s-pbe-n-van_gipaw.UPF + F: F.star1s-pbe-gipaw_kj.UPF + Li: Li.star1s-pbe-s-rrkjus-gipaw-test_2.UPF + O: O.star1s.pbe-n-kjpaw_gipaw.UPF + Si: Si.star1s-pbe-van_gipaw.UPF diff --git a/src/aiidalab_qe/plugins/xas/setting.py b/src/aiidalab_qe/plugins/xas/setting.py index f2f41fca1..2d80bd708 100644 --- a/src/aiidalab_qe/plugins/xas/setting.py +++ b/src/aiidalab_qe/plugins/xas/setting.py @@ -2,19 +2,19 @@ """Panel for XAS plugin. """ +from importlib import resources + import ipywidgets as ipw import traitlets as tl -from aiida import orm +import yaml from aiida.orm import StructureData from aiidalab_qe.common.panel import Panel +from aiidalab_qe.plugins import xas as xas_folder -xch_elements = ["Li", "C"] -dir_header = "cls_pseudos" -functionals = ["pbe"] -core_wfc_dir = "core_wfc_data" -gipaw_dir = "gipaw_pseudos" -ch_pseudo_dir = "ch_pseudos/star1s" +PSEUDO_TOC = yaml.safe_load(resources.read_text(xas_folder, "pseudo_toc.yaml")) +pseudo_data_dict = PSEUDO_TOC["pseudos"] +xch_elements = PSEUDO_TOC["xas_xch_elements"] class Setting(Panel): @@ -75,11 +75,9 @@ class Setting(Panel): ) def __init__(self, **kwargs): - self.gipaw_pseudo_group = orm.load_group("cls_pseudos/pbe/gipaw_pseudos") - self.core_hole_pseudos_group = orm.load_group( - "cls_pseudos/pbe/ch_pseudos/star1s" - ) - self.core_wfc_group = orm.load_group("cls_pseudos/pbe/core_wfc_data") + self.gipaw_pseudos = pseudo_data_dict["pbe"]["gipaw_pseudos"] + self.core_hole_pseudos = pseudo_data_dict["pbe"]["core_hole_pseudos"]["1s"] + self.core_wfc_data = pseudo_data_dict["pbe"]["core_wavefunction_data"] self.element_and_ch_treatment = ipw.VBox(layout=ipw.Layout(width="100%")) @@ -131,18 +129,18 @@ def get_panel_value(self): "core_hole_treatments": core_hole_treatments, "elements_list": elements_list, "structure_type": self.structure_type.value, - "gipaw_pseudo_group": self.gipaw_pseudo_group.label, - "ch_pseudo_group": self.core_hole_pseudos_group.label, - "core_wfc_data_group": self.core_wfc_group.label, + "gipaw_pseudo": self.gipaw_pseudos, + "ch_pseudo": self.core_hole_pseudos, + "core_wfc_data": self.core_wfc_data, } return parameters def set_panel_value(self, input_dict): """Load a dictionary with the input parameters for the plugin.""" - ch_group = self.core_hole_pseudos_group + ch_pseudos = self.core_hole_pseudos structure = self.input_structure - available_elements = [node.label.split(".")[0] for node in ch_group.nodes] + available_elements = [k for k in ch_pseudos] elements_to_select = sorted( [ kind.symbol @@ -175,15 +173,9 @@ def _update_element_select_panel(self): ("XCH (Smearing)", "xch_smear"), ("XCH (Fixed)", "xch_fixed"), ] - ch_group = self.core_hole_pseudos_group + ch_pseudos = self.core_hole_pseudos structure = self.input_structure - # (21/11/23) Temporarilly block Li from the list until issues with the - # current pseudo are resolved: - available_elements = [ - node.label.split(".")[0] - for node in ch_group.nodes - if node.label.split(".")[0] != "Li" - ] + available_elements = [k for k in ch_pseudos] elements_to_select = sorted( [ kind.symbol diff --git a/src/aiidalab_qe/plugins/xas/workchain.py b/src/aiidalab_qe/plugins/xas/workchain.py index 3d299478f..3d4c77a8c 100644 --- a/src/aiidalab_qe/plugins/xas/workchain.py +++ b/src/aiidalab_qe/plugins/xas/workchain.py @@ -1,41 +1,19 @@ import os import tarfile +from importlib import resources import requests +import yaml from aiida import orm from aiida.plugins import WorkflowFactory from aiida_quantumespresso.common.types import ElectronicType, SpinType -XspectraCrystalWorkChain = WorkflowFactory("quantumespresso.xspectra.crystal") - - -def load_or_create_group(group_label_string): - """Check for the existence of a group matching a label, and create one if not found.""" - - from aiida.orm import UpfFamily - - try: - group = orm.load_group(group_label_string) - except BaseException: - group = UpfFamily(group_label_string) - group.store() - - return group - - -def check_ch_pseudos_for_elements(group_label): - """Check a set of core-hole pseudos for which elements are available.""" - - from aiida import orm - - elements_list = [] - group = orm.load_group() - for node in group.nodes: - element = node.label.split(".")[0] - elements_list.append(element) - - return elements_list +from aiidalab_qe.plugins import xas as xas_folder +XspectraCrystalWorkChain = WorkflowFactory("quantumespresso.xspectra.crystal") +PSEUDO_TOC = yaml.safe_load(resources.read_text(xas_folder, "pseudo_toc.yaml")) +pseudo_data_dict = PSEUDO_TOC["pseudos"] +xch_elements = PSEUDO_TOC["xas_xch_elements"] base_url = "https://github.com/PNOGillespie/Core_Level_Spectra_Pseudos/raw/main" head_path = "/home/jovyan/Utils/QE/Pseudos" @@ -45,6 +23,43 @@ def check_ch_pseudos_for_elements(group_label): gipaw_dir = "gipaw_pseudos" ch_pseudo_dir = "ch_pseudos/star1s" + +def _load_or_import_nodes_from_filenames(in_dict, path, core_wfc_data=False): + for filename in in_dict.values(): + try: + orm.load_node(filename) + except BaseException: + if not core_wfc_data: + new_upf = orm.UpfData(f"{path}/{filename}", filename=filename) + new_upf.label = filename + new_upf.store() + else: + new_singlefile = orm.SinglefileData( + f"{path}/{filename}", filename="stdout" + ) + new_singlefile.label = filename + new_singlefile.store() + + +def _download_extract_pseudo_archive(func): + dir = f"{head_path}/{dir_header}/{func}" + archive_filename = f"{func}_ch_pseudos.tgz" + remote_archive_filename = f"{base_url}/{func}/{archive_filename}" + local_archive_filename = f"{dir}/{archive_filename}" + + env = os.environ.copy() + env["PATH"] = f"{env['PATH']}:{dir}" + + response = requests.get(remote_archive_filename, timeout=30) + response.raise_for_status() + with open(local_archive_filename, "wb") as handle: + handle.write(response.content) + handle.flush() + + with tarfile.open(local_archive_filename, "r:gz") as tarfil: + tarfil.extractall(dir) + + url = f"{base_url}" for func in functionals: dir = f"{head_path}/{dir_header}/{func}" @@ -55,66 +70,42 @@ def check_ch_pseudos_for_elements(group_label): if entry == archive_filename: archive_found = True if not archive_found: - remote_archive_filename = f"{base_url}/{func}/{archive_filename}" - local_archive_filename = f"{dir}/{archive_filename}" - - env = os.environ.copy() - env["PATH"] = f"{env['PATH']}:{dir}" - - response = requests.get(remote_archive_filename, timeout=30) - response.raise_for_status() - with open(local_archive_filename, "wb") as handle: - handle.write(response.content) - handle.flush() + _download_extract_pseudo_archive(func) - with tarfile.open(local_archive_filename, "r:gz") as tarfil: - tarfil.extractall(dir) -cls_group_head = load_or_create_group(dir_header) +# Check all the pseudos/core-wfc data files in the TOC dictionary +# and load/check all of them before proceeding. Note that this +# approach relies on there not being multiple instances of nodes +# with the same label. for func in functionals: - func_group = load_or_create_group(f"{dir_header}/{func}") - # Strictly speaking, this one won't have UPF data in it, but it *is* related - # to the other UPF data anyway. - core_wfc_group = load_or_create_group(f"{dir_header}/{func}/{core_wfc_dir}") - core_wfc_node_labels = [node.label for node in core_wfc_group.nodes] - core_wfc_nodes = [] - core_wfc_path = f"{head_path}/{core_wfc_group.label}" - for file in os.listdir(core_wfc_path): - if file not in core_wfc_node_labels: - new_singlefile = orm.SinglefileData( - f"{core_wfc_path}/{file}", filename="stdout" - ) - new_singlefile.label = file - new_singlefile.store() - core_wfc_nodes.append(new_singlefile) - if len(core_wfc_nodes) > 0: - core_wfc_group.add_nodes(core_wfc_nodes) - - gipaw_group = load_or_create_group(f"{dir_header}/{func}/{gipaw_dir}") - gipaw_node_labels = [node.label for node in gipaw_group.nodes] - gipaw_nodes = [] - gipaw_path = f"{head_path}/{gipaw_group.label}" - for file in os.listdir(gipaw_path): - if file not in gipaw_node_labels: - new_upf = orm.UpfData(f"{gipaw_path}/{file}", filename=file) - new_upf.label = file - new_upf.store() - gipaw_nodes.append(new_upf) - if len(gipaw_nodes) > 0: - gipaw_group.add_nodes(gipaw_nodes) - - ch_group = load_or_create_group(f"{dir_header}/{func}/{ch_pseudo_dir}") - ch_node_labels = [node.label for node in ch_group.nodes] - ch_nodes = [] - ch_path = f"{head_path}/{ch_group.label}" - for file in os.listdir(ch_path): - if file not in ch_node_labels: - new_upf = orm.UpfData(f"{ch_path}/{file}", filename=file) - new_upf.label = file - new_upf.store() - ch_nodes.append(new_upf) - if len(ch_nodes) > 0: - ch_group.add_nodes(ch_nodes) + gipaw_pseudo_dict = pseudo_data_dict[func]["gipaw_pseudos"] + core_wfc_dict = pseudo_data_dict[func]["core_wavefunction_data"] + core_hole_pseudo_dict = pseudo_data_dict[func]["core_hole_pseudos"] + main_path = f"{head_path}/{dir_header}/{func}" + core_wfc_dir = f"{main_path}/core_wfc_data" + gipaw_dir = f"{main_path}/gipaw_pseudos" + ch_pseudo_dir = f"{main_path}/ch_pseudos/star1s" + # First, check that the local directories contain what's in the pseudo_toc + for pseudo_dir, pseudo_dict in zip( + [gipaw_dir, core_wfc_dir, ch_pseudo_dir], + [gipaw_pseudo_dict, core_wfc_dict, core_hole_pseudo_dict], + ): + pseudo_toc_mismatch = os.listdir(pseudo_dir) != pseudo_dict.values() + + # Re-download the relevant archive if there is a mismatch + if pseudo_toc_mismatch: + _download_extract_pseudo_archive(func) + + _load_or_import_nodes_from_filenames( + in_dict=gipaw_pseudo_dict, + path=gipaw_dir, + ) + _load_or_import_nodes_from_filenames( + in_dict=core_wfc_dict, path=core_wfc_dir, core_wfc_data=True + ) + _load_or_import_nodes_from_filenames( + in_dict=core_hole_pseudo_dict["1s"], path=ch_pseudo_dir + ) def get_builder(codes, structure, parameters, **kwargs): @@ -122,26 +113,18 @@ def get_builder(codes, structure, parameters, **kwargs): protocol = parameters["workchain"]["protocol"] xas_parameters = parameters["xas"] - gipaw_pseudo_group = orm.load_group(xas_parameters["gipaw_pseudo_group"]) - ch_pseudo_group = orm.load_group(xas_parameters["ch_pseudo_group"]) - core_wfc_data_group = orm.load_group(xas_parameters["core_wfc_data_group"]) + gipaw_pseudos_dict = pseudo_data_dict["pbe"]["gipaw_pseudos"] + ch_pseudos_dict = pseudo_data_dict["pbe"]["core_hole_pseudos"]["1s"] + core_wfc_data_dict = pseudo_data_dict["pbe"]["core_wavefunction_data"] # set pseudo for element pseudos = {} core_wfc_data = {} core_hole_treatments = xas_parameters["core_hole_treatments"] elements_list = xas_parameters["elements_list"] for element in elements_list: - gipaw_pseudo = [ - gipaw_upf - for gipaw_upf in gipaw_pseudo_group.nodes - if gipaw_upf.element == element - ][0] - ch_pseudo = [ - ch_upf for ch_upf in ch_pseudo_group.nodes if ch_upf.element == element - ][0] - core_wfc_node = [ - sf for sf in core_wfc_data_group.nodes if sf.label.split(".")[0] == element - ][0] + gipaw_pseudo = orm.load_node(gipaw_pseudos_dict[element]) + ch_pseudo = orm.load_node(ch_pseudos_dict[element]) + core_wfc_node = orm.load_node(core_wfc_data_dict[element]) pseudos[element] = {"gipaw": gipaw_pseudo, "core_hole": ch_pseudo} core_wfc_data[element] = core_wfc_node @@ -192,18 +175,6 @@ def get_builder(codes, structure, parameters, **kwargs): }, } } - # TODO: We need to ensure that the family used for selecting the pseudopotentials in - # the CrystalWorkChain is set to the same as the functional needed for the - # core-hole/gipaw pseudo pair. In the future, this should check which one it is - # and set the correct value if it isn't already. - # For now, I've tried to override the pseudo family automatically using the example below, - # but it seems that the override is being *overriden* by some other part of the App - # chosen_pseudo_family = overrides["core"]["scf"]["pseudo_family"] - # if "/PBE/" not in chosen_pseudo_family: - # if chosen_pseudo_family.split("/")[0] == "PseudoDojo": - # overrides["core"]["scf"]["pseudo_family"] = "PseudoDojo/0.4/PBE/SR/standard/upf" - # elif chosen_pseudo_family.split("/")[0] == "SSSP": - # overrides["core"]["scf"]["pseudo_family"] = "SSSP/1.2/PBE/efficiency" builder = XspectraCrystalWorkChain.get_builder_from_protocol( pw_code=pw_code, From bc38545dedef6f43c5a9bd209f295e9bf53fadea Mon Sep 17 00:00:00 2001 From: "Peter N. O. Gillespie" Date: Mon, 4 Dec 2023 11:09:48 +0000 Subject: [PATCH 03/11] XAS: Migrate CLS Pseudos Directory Migrates the directory used for core-level spectra pseudos to `~/.local/lib/cls_pseudos` in order to allow integration tests to pass. --- src/aiidalab_qe/plugins/xas/workchain.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/aiidalab_qe/plugins/xas/workchain.py b/src/aiidalab_qe/plugins/xas/workchain.py index 3d4c77a8c..ee400481a 100644 --- a/src/aiidalab_qe/plugins/xas/workchain.py +++ b/src/aiidalab_qe/plugins/xas/workchain.py @@ -1,6 +1,7 @@ import os import tarfile from importlib import resources +from pathlib import Path import requests import yaml @@ -16,7 +17,7 @@ xch_elements = PSEUDO_TOC["xas_xch_elements"] base_url = "https://github.com/PNOGillespie/Core_Level_Spectra_Pseudos/raw/main" -head_path = "/home/jovyan/Utils/QE/Pseudos" +head_path = f"{Path.home()}/.local/lib" dir_header = "cls_pseudos" functionals = ["pbe"] core_wfc_dir = "core_wfc_data" @@ -48,7 +49,7 @@ def _download_extract_pseudo_archive(func): local_archive_filename = f"{dir}/{archive_filename}" env = os.environ.copy() - env["PATH"] = f"{env['PATH']}:{dir}" + env["PATH"] = f"{env['PATH']}:{Path.home() / '.local' / 'lib'}" response = requests.get(remote_archive_filename, timeout=30) response.raise_for_status() From a980dcfbd58e222a8194d6fd3423d83d20583585 Mon Sep 17 00:00:00 2001 From: "Peter N. O. Gillespie" Date: Wed, 13 Dec 2023 10:15:20 +0000 Subject: [PATCH 04/11] XAS Plugin: Various Improvements Adds a range of improvements and fixes to the plugin. Fixes: * Fixes an issue where the `Setting` panel of the plugin would cause a crash due to a variable being re-defined during runtime. * Adds a `.close()` statement to the end of the HTTP request when accessing the pseudo archive file for download. Additions: * The app now retrieves all contributions to the final spectrum for the selected element and plots each together. The spectra are aligned in terms of energy in the same way as the `XspectraCrystalWorkChain` uses to generate the final spectrum. Each contributing spectrum is scaled according to the ratio of site multiplicity to total multiplicity. The provided broadening tools will apply to all spectra on the widget simultaneously. * Adds a button to the `Result` panel to download a CSV file of the spectra presented on the Plotly widget. This will download a file titled `{element}_XAS_Spectra.csv` which contains all spectra on the widget (total spectrum and all site contributions). For sites with a multiplicity ratio different from 1, the weighted and unweighted spectra are printed in separate columns. --- src/aiidalab_qe/plugins/xas/result.py | 327 ++++++++++++++++++++--- src/aiidalab_qe/plugins/xas/setting.py | 8 +- src/aiidalab_qe/plugins/xas/workchain.py | 3 +- 3 files changed, 298 insertions(+), 40 deletions(-) diff --git a/src/aiidalab_qe/plugins/xas/result.py b/src/aiidalab_qe/plugins/xas/result.py index ad6149e20..acbb58d5a 100644 --- a/src/aiidalab_qe/plugins/xas/result.py +++ b/src/aiidalab_qe/plugins/xas/result.py @@ -1,13 +1,87 @@ """XAS results view widgets """ +import base64 +import hashlib +from typing import Callable + import ipywidgets as ipw import numpy as np +from IPython.display import HTML, display from scipy.interpolate import make_interp_spline from aiidalab_qe.common.panel import ResultPanel +class SpectrumDownloadButton(ipw.Button): + """Download button with dynamic content + The content is generated using a callback when the button is clicked. + Modified from responses to https://stackoverflow.com/questions/61708701/how-to-download-a-file-using-ipywidget-button#62641240 + """ + + def __init__(self, filename: str, contents: Callable[[], str], **kwargs): + super(SpectrumDownloadButton, self).__init__(**kwargs) + self.filename = filename + self.contents = contents + self.on_click(self.__on_click) + + def __on_click(self, b): + if self.contents() is None: + pass # to avoid a crash because NoneType obviously can't be processed here + else: + contents: bytes = self.contents().encode("utf-8") + b64 = base64.b64encode(contents) + payload = b64.decode() + digest = hashlib.md5(contents).hexdigest() # bypass browser cache + id = f"dl_{digest}" + + display( + HTML( + f""" + + + + + + + + """ + ) + ) + + +def write_csv(dataset): + from pandas import DataFrame + + x_vals = dataset[0]["x"] + df_data = {"energy_ev": x_vals} + # header = [] + for entry in dataset: + # df_data[f'{entry["name"]}(weight_{entry["weighting_string"]})'] = entry["y"] + if "site" in entry["name"]: + if entry["weighting"] != 1: + df_data[ + f'{entry["name"].capitalize().replace("_", " ")} (Weighted)' + ] = entry["y"] + df_data[ + f'{entry["name"].capitalize().replace("_", " ")} (Unweighted)' + ] = (entry["y"] / entry["weighting"]) + else: + df_data[entry["name"].capitalize().replace("_", " ")] = entry["y"] + else: + df_data[entry["name"]] = entry["y"] + # header.append(f'weighting={str(entry["weighting_string"])}') + + df = DataFrame(data=df_data) + df_energy_indexed = df.set_index("energy_ev") + + return df_energy_indexed.to_csv(header=True) + + def export_xas_data(outputs): if "final_spectra" in outputs.xas: final_spectra = outputs.xas.final_spectra @@ -96,6 +170,78 @@ def broaden_xas( return np.column_stack((x_vals, lorenz_y)) +def get_aligned_spectra(core_wc_dict, equivalent_sites_dict): + """Return a set of spectra aligned according to the chemical shift (difference in Fermi level). + + Primarily this is a copy of ``get_spectra_by_element`` from AiiDA-QE which operates on only one + element. + """ + data_dict = {} + spectrum_dict = { + site: node.outputs.powder_spectrum for site, node in core_wc_dict.items() + } + for key, value in core_wc_dict.items(): + xspectra_out_params = value.outputs.parameters_xspectra__xas_0.get_dict() + energy_zero = xspectra_out_params["energy_zero"] + multiplicity = equivalent_sites_dict[key]["multiplicity"] + + if "total_multiplicity" not in data_dict: + data_dict["total_multiplicity"] = multiplicity + else: + data_dict["total_multiplicity"] += multiplicity + + data_dict[key] = { + "spectrum_node": spectrum_dict[key], + "multiplicity": multiplicity, + "energy_zero": energy_zero, + } + + spectra_list = [] + total_multiplicity = data_dict.pop("total_multiplicity") + for key in data_dict: + spectrum_node = data_dict[key]["spectrum_node"] + site_multiplicity = data_dict[key]["multiplicity"] + weighting = site_multiplicity / total_multiplicity + weighting_string = f"{site_multiplicity}/{total_multiplicity}" + spectrum_x = spectrum_node.get_x()[1] + spectrum_y = spectrum_node.get_y()[0][1] + spline = make_interp_spline(spectrum_x, spectrum_y) + norm_y = spline(spectrum_x) / np.trapz(spline(spectrum_x), spectrum_x) + weighted_spectrum = np.column_stack( + (spectrum_x, norm_y * (site_multiplicity / total_multiplicity)) + ) + spectra_list.append( + ( + weighted_spectrum, + key, + weighting, + weighting_string, + float(data_dict[key]["energy_zero"]), + ) + ) + + # Sort according to Fermi level, then correct to align all spectra to the + # highest value. Note that this is needed because XSpectra automatically aligns the + # final spectrum such that the system's Fermi level is at 0 eV. + spectra_list.sort(key=lambda entry: entry[-1]) + highest_level = spectra_list[0][-1] + energy_zero_corrections = [ + (entry[0], entry[1], entry[2], entry[3], entry[-1] - highest_level) + for entry in spectra_list + ] + aligned_spectra = [ + ( + entry[1], + entry[2], + entry[3], + np.column_stack((entry[0][:, 0] - entry[-1], entry[0][:, 1])), + ) + for entry in energy_zero_corrections + ] + + return aligned_spectra + + class Result(ResultPanel): title = "XAS" workchain_labels = ["xas"] @@ -134,7 +280,18 @@ def _update_view(self):
Select spectrum to plot
""" ) - final_spectra, _ = export_xas_data(self.outputs) + final_spectra, equivalent_sites_data = export_xas_data(self.outputs) + xas_wc = [ + n for n in self.node.called if n.process_label == "XspectraCrystalWorkChain" + ][0] + core_wcs = { + n.get_metadata_inputs()["metadata"]["call_link_label"]: n + for n in xas_wc.called + if n.process_label == "XspectraCoreWorkChain" + } + core_wc_dict = { + key.replace("_xspectra", ""): value for key, value in core_wcs.items() + } spectrum_select_options = [key.split("_")[0] for key in final_spectra.keys()] @@ -188,7 +345,12 @@ def _update_view(self): orientation="horizontal", readout=True, ) - + download_data = SpectrumDownloadButton( + filename="spectra.csv", + contents=None, + description="Download CSV", + icon="download", + ) # # get data # # init figure g = go.FigureWidget( @@ -204,54 +366,151 @@ def _update_view(self): chosen_spectrum_label = f"{chosen_spectrum}_xas" spectra = final_spectra[chosen_spectrum_label] - # for spectrum_label, data in spectra.items(): - element = chosen_spectrum_label.split("_")[0] raw_spectrum = np.column_stack((spectra.get_x()[1], spectra.get_y()[0][1])) + x = raw_spectrum[:, 0] y = raw_spectrum[:, 1] spline = make_interp_spline(x, y) norm_y = spline(x) / np.trapz(spline(x), x) - g.add_scatter(x=x, y=norm_y, name=element) + element = chosen_spectrum_label.split("_")[0] + element_sites = [ + key + for key in equivalent_sites_data + if equivalent_sites_data[key]["symbol"] == element + ] + element_core_wcs = {} + total_multiplicity = 0 + for site in element_sites: + site_multiplicity = equivalent_sites_data[site]["multiplicity"] + total_multiplicity += site_multiplicity + element_core_wcs[site] = core_wc_dict[site] + + g.add_scatter(x=x, y=norm_y, name=f"{element} K-edge") + for entry in get_aligned_spectra( + core_wc_dict=element_core_wcs, equivalent_sites_dict=equivalent_sites_data + ): + g.add_scatter( + x=entry[-1][:, 0], + y=entry[-1][:, 1], + name=entry[0].capitalize().replace("_", " "), + ) + + def _update_download_selection(dataset, element): + download_data.contents = lambda: write_csv(dataset) + download_data.filename = f"{element}_XAS_Spectra.csv" def response(change): chosen_spectrum = spectrum_select.value chosen_spectrum_label = f"{chosen_spectrum}_xas" - spectra = final_spectra[chosen_spectrum_label] - raw_spectrum = np.column_stack((spectra.get_x()[1], spectra.get_y()[0][1])) - x = raw_spectrum[:, 0] - y = raw_spectrum[:, 1] - if not variable_broad_select: - gamma_max_select.disabled = True - center_e_select.disabled = True - else: - gamma_max_select.disabled = False - center_e_select.disabled = False - - if gamma_hole_select.value == 0.0: + element_sites = [ + key + for key in equivalent_sites_data + if equivalent_sites_data[key]["symbol"] == chosen_spectrum + ] + element_core_wcs = { + key: value + for key, value in core_wc_dict.items() + if key in element_sites + } + spectra = [] + final_spectrum_node = final_spectra[chosen_spectrum_label] + final_spectrum = np.column_stack( + (final_spectrum_node.get_x()[1], final_spectrum_node.get_y()[0][1]) + ) + final_x_vals = final_spectrum[:, 0] + final_y_vals = final_spectrum[:, 1] + final_spectrum_spline = make_interp_spline(final_x_vals, final_y_vals) + final_norm_y = final_spectrum_spline(final_x_vals) / np.trapz( + final_spectrum_spline(final_x_vals), final_x_vals + ) + spectra.append( + ( + f"{chosen_spectrum} K-edge", + 1, + "1", + np.column_stack((final_x_vals, final_norm_y)), + ) + ) + datasets = [] + for entry in get_aligned_spectra( + core_wc_dict=element_core_wcs, + equivalent_sites_dict=equivalent_sites_data, + ): + spectra.append(entry) + + for entry in spectra: + label = entry[0] + weighting = entry[1] + weighting_string = entry[2] + raw_spectrum = entry[-1] x = raw_spectrum[:, 0] y = raw_spectrum[:, 1] - else: - broad_spectrum = broaden_xas( - raw_spectrum, - gamma_hole=gamma_hole_select.value, - gamma_max=gamma_max_select.value, - center_energy=center_e_select.value, - variable=variable_broad_select.value, + if not variable_broad_select: + gamma_max_select.disabled = True + center_e_select.disabled = True + else: + gamma_max_select.disabled = False + center_e_select.disabled = False + + if gamma_hole_select.value == 0.0: + x = raw_spectrum[:, 0] + y = raw_spectrum[:, 1] + else: + broad_spectrum = broaden_xas( + raw_spectrum, + gamma_hole=gamma_hole_select.value, + gamma_max=gamma_max_select.value, + center_energy=center_e_select.value, + variable=variable_broad_select.value, + ) + x = broad_spectrum[:, 0] + y = broad_spectrum[:, 1] + + final_spline = make_interp_spline(x, y) + final_y_vals = final_spline(final_x_vals) + datasets.append( + { + "x": final_x_vals, + "y": final_y_vals, + "name": label, + "weighting": weighting, + "weighting_string": weighting_string, + } ) - x = broad_spectrum[:, 0] - y = broad_spectrum[:, 1] - - spline = make_interp_spline(x, y) - norm_y = spline(x) / np.trapz(spline(x), x) - - g.update(data=[{"x": x, "y": norm_y, "name": chosen_spectrum}]) + _update_download_selection(datasets, chosen_spectrum) + + with g.batch_update(): + # If the number of datasets is different from one update to the next, + # then we need to reset the data already in the Widget. Otherwise, we can + # simply override the data. This also helps since then changing the + # broadening is much smoother. + if len(datasets) == len( + g.data + ): # if the number of entries is the same, just update + for index, entry in enumerate(datasets): + g.data[index].x = entry["x"] + g.data[index].y = entry["y"] + if "site_" in entry["name"]: + g.data[index].name = ( + entry["name"].capitalize().replace("_", " ") + ) + else: + g.data[index].name = entry["name"] + else: # otherwise, reset the figure + g.data = () + for entry in datasets: + if "site_" in entry["name"]: + name = entry["name"].capitalize().replace("_", " ") + else: + name = entry["name"] + g.add_scatter(x=entry["x"], y=entry["y"], name=name) spectrum_select.observe(response, names="value") gamma_hole_select.observe(response, names="value") gamma_max_select.observe(response, names="value") center_e_select.observe(response, names="value") variable_broad_select.observe(response, names="value") - + download_data.observe(response, names=["contents", "filename"]) self.children = [ ipw.HBox( [ @@ -270,13 +529,11 @@ def response(change): [ variable_broad_select, variable_broad_select_help, - # variable_broad_select_prompt, ], layout=ipw.Layout(width="60%"), ), - # ipw.VBox([ - # ], layout=ipw.Layout(width="60%")), ] ), + download_data, g, ] diff --git a/src/aiidalab_qe/plugins/xas/setting.py b/src/aiidalab_qe/plugins/xas/setting.py index 2d80bd708..7b75bf51b 100644 --- a/src/aiidalab_qe/plugins/xas/setting.py +++ b/src/aiidalab_qe/plugins/xas/setting.py @@ -149,14 +149,14 @@ def set_panel_value(self, input_dict): ] ) - element_and_ch_treatment = {} + element_and_ch_treatment_options = {} for element in elements_to_select: if element in xch_elements: - element_and_ch_treatment[element] = "xch_smear" + element_and_ch_treatment_options[element] = "xch_smear" else: - element_and_ch_treatment[element] = "full" + element_and_ch_treatment_options[element] = "full" - self.element_and_ch_treatment = element_and_ch_treatment + self.element_and_ch_treatment_options = element_and_ch_treatment_options self.structure_type.value = input_dict.get("structure_type", "crystal") @tl.observe("input_structure") diff --git a/src/aiidalab_qe/plugins/xas/workchain.py b/src/aiidalab_qe/plugins/xas/workchain.py index ee400481a..e48ba6476 100644 --- a/src/aiidalab_qe/plugins/xas/workchain.py +++ b/src/aiidalab_qe/plugins/xas/workchain.py @@ -56,6 +56,7 @@ def _download_extract_pseudo_archive(func): with open(local_archive_filename, "wb") as handle: handle.write(response.content) handle.flush() + response.close() with tarfile.open(local_archive_filename, "r:gz") as tarfil: tarfil.extractall(dir) @@ -173,7 +174,7 @@ def get_builder(codes, structure, parameters, **kwargs): } } } - }, + } } } From ed59020069ff7a82a8aef19e916458ca38ddb53a Mon Sep 17 00:00:00 2001 From: "Peter N. O. Gillespie" Date: Wed, 13 Dec 2023 10:31:35 +0000 Subject: [PATCH 05/11] XAS: Fix for Pre-Commit Checks --- src/aiidalab_qe/plugins/xas/workchain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aiidalab_qe/plugins/xas/workchain.py b/src/aiidalab_qe/plugins/xas/workchain.py index e48ba6476..deba5f5ed 100644 --- a/src/aiidalab_qe/plugins/xas/workchain.py +++ b/src/aiidalab_qe/plugins/xas/workchain.py @@ -174,7 +174,7 @@ def get_builder(codes, structure, parameters, **kwargs): } } } - } + }, } } From 3f6acbceb603cc282e1333674e3b4b7ddb0f84a2 Mon Sep 17 00:00:00 2001 From: "Peter N. O. Gillespie" Date: Wed, 13 Dec 2023 12:29:24 +0000 Subject: [PATCH 06/11] XAS: Fix Typo in `variable_broad_select_help` --- src/aiidalab_qe/plugins/xas/result.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aiidalab_qe/plugins/xas/result.py b/src/aiidalab_qe/plugins/xas/result.py index acbb58d5a..2f79eb9a4 100644 --- a/src/aiidalab_qe/plugins/xas/result.py +++ b/src/aiidalab_qe/plugins/xas/result.py @@ -268,7 +268,7 @@ def _update_view(self):

Γhole - Defines a constant Lorenzian broadening width for the whole spectrum. In "variable" mode, defines the initial broadening width of the ArcTangent function.

Γmax - Maximum Lorenzian broadening parameter at infinte energy in "variable" mode.

-

Emax - Defines the inflection point of the variable-energy broadening function.

+

Ecenter - Defines the inflection point of the variable-energy broadening function.

Note that setting Γhole to 0 eV will simply plot the raw spectrum. From 9a9f16269185b2a136b7914d095c6d41959dc9f1 Mon Sep 17 00:00:00 2001 From: "Peter N. O. Gillespie" Date: Wed, 24 Jan 2024 15:07:22 +0000 Subject: [PATCH 07/11] `XAS`: Enable `supercell_min_parameter` and Clean Code Enables the `supercell_min_parameter` setting for XAS calculations Also removes various extraneous lines of code which were commented out, had typos, or were requested for deletion. --- src/aiidalab_qe/plugins/xas/result.py | 8 ++--- src/aiidalab_qe/plugins/xas/setting.py | 43 ++++++++++++------------ src/aiidalab_qe/plugins/xas/workchain.py | 26 ++++++-------- 3 files changed, 34 insertions(+), 43 deletions(-) diff --git a/src/aiidalab_qe/plugins/xas/result.py b/src/aiidalab_qe/plugins/xas/result.py index 2f79eb9a4..8b6bf064f 100644 --- a/src/aiidalab_qe/plugins/xas/result.py +++ b/src/aiidalab_qe/plugins/xas/result.py @@ -59,9 +59,7 @@ def write_csv(dataset): x_vals = dataset[0]["x"] df_data = {"energy_ev": x_vals} - # header = [] for entry in dataset: - # df_data[f'{entry["name"]}(weight_{entry["weighting_string"]})'] = entry["y"] if "site" in entry["name"]: if entry["weighting"] != 1: df_data[ @@ -74,7 +72,6 @@ def write_csv(dataset): df_data[entry["name"].capitalize().replace("_", " ")] = entry["y"] else: df_data[entry["name"]] = entry["y"] - # header.append(f'weighting={str(entry["weighting_string"])}') df = DataFrame(data=df_data) df_energy_indexed = df.set_index("energy_ev") @@ -132,7 +129,6 @@ def broaden_xas( x_vals = input_array[:, 0] y_vals = input_array[:, 1] - # y_vals_norm = np.array(y_vals/np.trapz(y_vals, x_vals)) lorenz_y = np.zeros(len(x_vals)) @@ -147,7 +143,7 @@ def broaden_xas( 0.5 + np.arctan((e - 1) / (e**2)) / np.pi ) - if x <= 1.0e-6: # do this to avoid trying to broaden values close to 0 + if y <= 1.0e-6: # do this to skip the calculation for very small values lorenz_y = y else: lorenz_y += ( @@ -346,7 +342,7 @@ def _update_view(self): readout=True, ) download_data = SpectrumDownloadButton( - filename="spectra.csv", + filename=f"{spectrum_select.value}_XAS_Spectra.csv", contents=None, description="Download CSV", icon="download", diff --git a/src/aiidalab_qe/plugins/xas/setting.py b/src/aiidalab_qe/plugins/xas/setting.py index 7b75bf51b..12daff8f2 100644 --- a/src/aiidalab_qe/plugins/xas/setting.py +++ b/src/aiidalab_qe/plugins/xas/setting.py @@ -51,16 +51,16 @@ class Setting(Panel): # I will leave these objects here for now (15/11/23), but since the calculation of molecular # systems is not really supported (neither in terms of XAS nor the main App itself) we should # not present this option that essentially does nothing. - structure_title = ipw.HTML( - """
-

Structure

""" - ) - structure_help = ipw.HTML( - """
- Below you can indicate if the material should be treated as a molecule - or a crystal. -
""" - ) + # structure_title = ipw.HTML( + # """
+ #

Structure

""" + # ) + # structure_help = ipw.HTML( + # """
+ # Below you can indicate if the material should be treated as a molecule + # or a crystal. + #
""" + # ) supercell_title = ipw.HTML( """

Cell size

""" @@ -81,13 +81,13 @@ def __init__(self, **kwargs): self.element_and_ch_treatment = ipw.VBox(layout=ipw.Layout(width="100%")) - self.structure_type = ipw.ToggleButtons( - options=[ - ("Molecule", "molecule"), - ("Crystal", "crystal"), - ], - value="crystal", - ) + # self.structure_type = ipw.ToggleButtons( + # options=[ + # ("Molecule", "molecule"), + # ("Crystal", "crystal"), + # ], + # value="crystal", + # ) self.supercell_min_parameter = ipw.FloatText( value=8.0, description="The minimum cell length (Å):", @@ -128,10 +128,11 @@ def get_panel_value(self): parameters = { "core_hole_treatments": core_hole_treatments, "elements_list": elements_list, - "structure_type": self.structure_type.value, + # "structure_type": self.structure_type.value, "gipaw_pseudo": self.gipaw_pseudos, "ch_pseudo": self.core_hole_pseudos, "core_wfc_data": self.core_wfc_data, + "supercell_min_parameter": self.supercell_min_parameter.value, } return parameters @@ -157,7 +158,7 @@ def set_panel_value(self, input_dict): element_and_ch_treatment_options[element] = "full" self.element_and_ch_treatment_options = element_and_ch_treatment_options - self.structure_type.value = input_dict.get("structure_type", "crystal") + # self.structure_type.value = input_dict.get("structure_type", "crystal") @tl.observe("input_structure") def _update_structure(self, _=None): @@ -220,8 +221,6 @@ def _update_element_select_panel(self): self.element_and_ch_treatment.children = treatment_options - # TODO: Maybe find a way to cut back the nesting by 1 at least, since I don't think that there should be this much - # For reference: # This is the whole widget: # print(f"{self.element_and_ch_treatment}\n") @@ -241,4 +240,4 @@ def _update_element_select_panel(self): def reset(self): """Reset the panel to its initial state.""" self.input_structure = None - self.structure_type.value = "crystal" + # self.structure_type.value = "crystal" diff --git a/src/aiidalab_qe/plugins/xas/workchain.py b/src/aiidalab_qe/plugins/xas/workchain.py index deba5f5ed..64d822578 100644 --- a/src/aiidalab_qe/plugins/xas/workchain.py +++ b/src/aiidalab_qe/plugins/xas/workchain.py @@ -123,6 +123,8 @@ def get_builder(codes, structure, parameters, **kwargs): core_wfc_data = {} core_hole_treatments = xas_parameters["core_hole_treatments"] elements_list = xas_parameters["elements_list"] + supercell_min_parameter = xas_parameters["supercell_min_parameter"] + for element in elements_list: gipaw_pseudo = orm.load_node(gipaw_pseudos_dict[element]) ch_pseudo = orm.load_node(ch_pseudos_dict[element]) @@ -137,16 +139,18 @@ def get_builder(codes, structure, parameters, **kwargs): # (13/10/23) I'm keeping the part about molecules in for future reference, # but we need to establish the protocol & backend code for XAS of molecules # before thinking about a workflow. - is_molecule_input = ( - True if xas_parameters.get("structure_type") == "molecule" else False - ) + # (22/01/24) Commented out the code for molecules, just so the option doesn't + # appear in the UI and confuse the user. + # is_molecule_input = ( + # True if xas_parameters.get("structure_type") == "molecule" else False + # ) # core_hole_treatment = xas_parameters["core_hole_treatment"] # core_hole_treatments = {element: core_hole_treatment for element in elements_list} structure_preparation_settings = { - # "supercell_min_parameter": Float(supercell_min_parameter_map[protocol]), - "is_molecule_input": orm.Bool(is_molecule_input), + "supercell_min_parameter": orm.Float(supercell_min_parameter), + # "is_molecule_input": orm.Bool(is_molecule_input), } spglib_settings = orm.Dict({"symprec": 1.0e-3}) @@ -187,7 +191,6 @@ def get_builder(codes, structure, parameters, **kwargs): elements_list=elements_list, core_hole_treatments=core_hole_treatments, core_wfc_data=core_wfc_data, - structure_preparation_settings=structure_preparation_settings, electronic_type=ElectronicType(parameters["workchain"]["electronic_type"]), spin_type=SpinType(parameters["workchain"]["spin_type"]), # TODO: We will need to merge the changes in AiiDA-QE PR#969 in order @@ -200,15 +203,8 @@ def get_builder(codes, structure, parameters, **kwargs): builder.pop("relax") builder.pop("clean_workdir", None) builder.spglib_settings = spglib_settings - # there is a bug in aiida-quantumespresso xps, that one can not set the kpoints - # this is fxied in a PR, but we need to wait for the next release. - # we set a large kpoints_distance value to set the kpoints to 1x1x1 - if is_molecule_input: - # kpoints = KpointsData() - # kpoints.set_kpoints_mesh([1, 1, 1]) - # parameters["advanced"]["kpoints"] = kpoints - # builder.ch_scf.kpoints_distance = Float(5) - pass + builder.structure_preparation_settings = structure_preparation_settings + return builder From 1b24462640860be5bb614ec46d3b04b444274ea4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 24 Jan 2024 15:11:39 +0000 Subject: [PATCH 08/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/aiidalab_qe/plugins/xas/result.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aiidalab_qe/plugins/xas/result.py b/src/aiidalab_qe/plugins/xas/result.py index 8b6bf064f..3dc930261 100644 --- a/src/aiidalab_qe/plugins/xas/result.py +++ b/src/aiidalab_qe/plugins/xas/result.py @@ -67,7 +67,7 @@ def write_csv(dataset): ] = entry["y"] df_data[ f'{entry["name"].capitalize().replace("_", " ")} (Unweighted)' - ] = (entry["y"] / entry["weighting"]) + ] = entry["y"] / entry["weighting"] else: df_data[entry["name"].capitalize().replace("_", " ")] = entry["y"] else: From 57ceb0d511d8e3d6c23cab78f2d96ff0c0a7ca9c Mon Sep 17 00:00:00 2001 From: "Peter N. O. Gillespie" Date: Fri, 26 Jan 2024 16:01:57 +0000 Subject: [PATCH 09/11] `XAS`: Move Pseudo Load to `setting.py` and clean code Changes requested changes for PR #580: * Moves the loading of pseudos and core wavefunction data to `setting.py`. `setting.py` now collects labels for pseudos and core_wfc_data, while `workchain.py` loads the nodes from the labels. * Replaces `set_panel_value()` content with call to `self._update_element_select_panel()`. * Fixes typos and removes redundant code noted in latest review. --- src/aiidalab_qe/plugins/xas/result.py | 2 +- src/aiidalab_qe/plugins/xas/setting.py | 156 +++++++++++++++++++---- src/aiidalab_qe/plugins/xas/workchain.py | 120 ++--------------- 3 files changed, 139 insertions(+), 139 deletions(-) diff --git a/src/aiidalab_qe/plugins/xas/result.py b/src/aiidalab_qe/plugins/xas/result.py index 3dc930261..c6874c09d 100644 --- a/src/aiidalab_qe/plugins/xas/result.py +++ b/src/aiidalab_qe/plugins/xas/result.py @@ -26,7 +26,7 @@ def __init__(self, filename: str, contents: Callable[[], str], **kwargs): self.on_click(self.__on_click) def __on_click(self, b): - if self.contents() is None: + if self.contents is None: pass # to avoid a crash because NoneType obviously can't be processed here else: contents: bytes = self.contents().encode("utf-8") diff --git a/src/aiidalab_qe/plugins/xas/setting.py b/src/aiidalab_qe/plugins/xas/setting.py index 12daff8f2..95fc7bc80 100644 --- a/src/aiidalab_qe/plugins/xas/setting.py +++ b/src/aiidalab_qe/plugins/xas/setting.py @@ -2,12 +2,16 @@ """Panel for XAS plugin. """ +import os +import tarfile from importlib import resources +from pathlib import Path import ipywidgets as ipw +import requests import traitlets as tl import yaml -from aiida.orm import StructureData +from aiida import orm from aiidalab_qe.common.panel import Panel from aiidalab_qe.plugins import xas as xas_folder @@ -16,11 +20,104 @@ pseudo_data_dict = PSEUDO_TOC["pseudos"] xch_elements = PSEUDO_TOC["xas_xch_elements"] +base_url = "https://github.com/PNOGillespie/Core_Level_Spectra_Pseudos/raw/main" +head_path = f"{Path.home()}/.local/lib" +dir_header = "cls_pseudos" +functionals = ["pbe"] +core_wfc_dir = "core_wfc_data" +gipaw_dir = "gipaw_pseudos" +ch_pseudo_dir = "ch_pseudos/star1s" + + +def _load_or_import_nodes_from_filenames(in_dict, path, core_wfc_data=False): + for filename in in_dict.values(): + try: + orm.load_node(filename) + except BaseException: + if not core_wfc_data: + new_upf = orm.UpfData(f"{path}/{filename}", filename=filename) + new_upf.label = filename + new_upf.store() + else: + new_singlefile = orm.SinglefileData( + f"{path}/{filename}", filename="stdout" + ) + new_singlefile.label = filename + new_singlefile.store() + + +def _download_extract_pseudo_archive(func): + dir = f"{head_path}/{dir_header}/{func}" + archive_filename = f"{func}_ch_pseudos.tgz" + remote_archive_filename = f"{base_url}/{func}/{archive_filename}" + local_archive_filename = f"{dir}/{archive_filename}" + + env = os.environ.copy() + env["PATH"] = f"{env['PATH']}:{Path.home() / '.local' / 'lib'}" + + response = requests.get(remote_archive_filename, timeout=30) + response.raise_for_status() + with open(local_archive_filename, "wb") as handle: + handle.write(response.content) + handle.flush() + response.close() + + with tarfile.open(local_archive_filename, "r:gz") as tarfil: + tarfil.extractall(dir) + + +url = f"{base_url}" +for func in functionals: + dir = f"{head_path}/{dir_header}/{func}" + os.makedirs(dir, exist_ok=True) + archive_filename = f"{func}_ch_pseudos.tgz" + archive_found = False + for entry in os.listdir(dir): + if entry == archive_filename: + archive_found = True + if not archive_found: + _download_extract_pseudo_archive(func) + + +# Check all the pseudos/core-wfc data files in the TOC dictionary +# and load/check all of them before proceeding. Note that this +# approach relies on there not being multiple instances of nodes +# with the same label. +for func in functionals: + gipaw_pseudo_dict = pseudo_data_dict[func]["gipaw_pseudos"] + core_wfc_dict = pseudo_data_dict[func]["core_wavefunction_data"] + core_hole_pseudo_dict = pseudo_data_dict[func]["core_hole_pseudos"] + main_path = f"{head_path}/{dir_header}/{func}" + core_wfc_dir = f"{main_path}/core_wfc_data" + gipaw_dir = f"{main_path}/gipaw_pseudos" + ch_pseudo_dir = f"{main_path}/ch_pseudos/star1s" + # First, check that the local directories contain what's in the pseudo_toc + for pseudo_dir, pseudo_dict in zip( + [gipaw_dir, core_wfc_dir, ch_pseudo_dir], + [gipaw_pseudo_dict, core_wfc_dict, core_hole_pseudo_dict], + ): + pseudo_toc_mismatch = os.listdir(pseudo_dir) != pseudo_dict.values() + + # Re-download the relevant archive if there is a mismatch + if pseudo_toc_mismatch: + _download_extract_pseudo_archive(func) + + _load_or_import_nodes_from_filenames( + in_dict=gipaw_pseudo_dict, + path=gipaw_dir, + ) + _load_or_import_nodes_from_filenames( + in_dict=core_wfc_dict, path=core_wfc_dir, core_wfc_data=True + ) + _load_or_import_nodes_from_filenames( + in_dict=core_hole_pseudo_dict["1s"], path=ch_pseudo_dir + ) + class Setting(Panel): title = "XAS Settings" identifier = "xas" - input_structure = tl.Instance(StructureData, allow_none=True) + input_structure = tl.Instance(orm.StructureData, allow_none=True) protocol = tl.Unicode(allow_none=True) element_selection_title = ipw.HTML( @@ -77,7 +174,7 @@ class Setting(Panel): def __init__(self, **kwargs): self.gipaw_pseudos = pseudo_data_dict["pbe"]["gipaw_pseudos"] self.core_hole_pseudos = pseudo_data_dict["pbe"]["core_hole_pseudos"]["1s"] - self.core_wfc_data = pseudo_data_dict["pbe"]["core_wavefunction_data"] + self.core_wfc_data_dict = pseudo_data_dict["pbe"]["core_wavefunction_data"] self.element_and_ch_treatment = ipw.VBox(layout=ipw.Layout(width="100%")) @@ -114,8 +211,6 @@ def __init__(self, **kwargs): super().__init__(**kwargs) def get_panel_value(self): - """Return a dictionary with the input parameters for the plugin.""" - elements_list = [] core_hole_treatments = {} for entry in self.element_and_ch_treatment.children: @@ -125,39 +220,48 @@ def get_panel_value(self): elements_list.append(element) core_hole_treatments[element] = ch_treatment + pseudo_labels = {} + core_wfc_data_labels = {} + for element in elements_list: + pseudo_labels[element] = { + "gipaw": self.gipaw_pseudos[element], + "core_hole": self.core_hole_pseudos[element], + } + core_wfc_data_labels[element] = self.core_wfc_data_dict[element] + parameters = { "core_hole_treatments": core_hole_treatments, "elements_list": elements_list, # "structure_type": self.structure_type.value, - "gipaw_pseudo": self.gipaw_pseudos, - "ch_pseudo": self.core_hole_pseudos, - "core_wfc_data": self.core_wfc_data, + "pseudo_labels": pseudo_labels, + "core_wfc_data_labels": core_wfc_data_labels, "supercell_min_parameter": self.supercell_min_parameter.value, } return parameters - def set_panel_value(self, input_dict): + def set_panel_value(self): """Load a dictionary with the input parameters for the plugin.""" - ch_pseudos = self.core_hole_pseudos - structure = self.input_structure - available_elements = [k for k in ch_pseudos] - elements_to_select = sorted( - [ - kind.symbol - for kind in structure.kinds - if kind.symbol in available_elements - ] - ) + # ch_pseudos = self.core_hole_pseudos + # structure = self.input_structure + # available_elements = [k for k in ch_pseudos] + # elements_to_select = sorted( + # [ + # kind.symbol + # for kind in structure.kinds + # if kind.symbol in available_elements + # ] + # ) - element_and_ch_treatment_options = {} - for element in elements_to_select: - if element in xch_elements: - element_and_ch_treatment_options[element] = "xch_smear" - else: - element_and_ch_treatment_options[element] = "full" + # element_and_ch_treatment_options = {} + # for element in elements_to_select: + # if element in xch_elements: + # element_and_ch_treatment_options[element] = "xch_smear" + # else: + # element_and_ch_treatment_options[element] = "full" - self.element_and_ch_treatment_options = element_and_ch_treatment_options + # self.element_and_ch_treatment_options = element_and_ch_treatment_options + self._update_element_select_panel() # self.structure_type.value = input_dict.get("structure_type", "crystal") @tl.observe("input_structure") diff --git a/src/aiidalab_qe/plugins/xas/workchain.py b/src/aiidalab_qe/plugins/xas/workchain.py index 64d822578..bfd175053 100644 --- a/src/aiidalab_qe/plugins/xas/workchain.py +++ b/src/aiidalab_qe/plugins/xas/workchain.py @@ -1,9 +1,5 @@ -import os -import tarfile from importlib import resources -from pathlib import Path -import requests import yaml from aiida import orm from aiida.plugins import WorkflowFactory @@ -16,121 +12,24 @@ pseudo_data_dict = PSEUDO_TOC["pseudos"] xch_elements = PSEUDO_TOC["xas_xch_elements"] -base_url = "https://github.com/PNOGillespie/Core_Level_Spectra_Pseudos/raw/main" -head_path = f"{Path.home()}/.local/lib" -dir_header = "cls_pseudos" -functionals = ["pbe"] -core_wfc_dir = "core_wfc_data" -gipaw_dir = "gipaw_pseudos" -ch_pseudo_dir = "ch_pseudos/star1s" - - -def _load_or_import_nodes_from_filenames(in_dict, path, core_wfc_data=False): - for filename in in_dict.values(): - try: - orm.load_node(filename) - except BaseException: - if not core_wfc_data: - new_upf = orm.UpfData(f"{path}/{filename}", filename=filename) - new_upf.label = filename - new_upf.store() - else: - new_singlefile = orm.SinglefileData( - f"{path}/{filename}", filename="stdout" - ) - new_singlefile.label = filename - new_singlefile.store() - - -def _download_extract_pseudo_archive(func): - dir = f"{head_path}/{dir_header}/{func}" - archive_filename = f"{func}_ch_pseudos.tgz" - remote_archive_filename = f"{base_url}/{func}/{archive_filename}" - local_archive_filename = f"{dir}/{archive_filename}" - - env = os.environ.copy() - env["PATH"] = f"{env['PATH']}:{Path.home() / '.local' / 'lib'}" - - response = requests.get(remote_archive_filename, timeout=30) - response.raise_for_status() - with open(local_archive_filename, "wb") as handle: - handle.write(response.content) - handle.flush() - response.close() - - with tarfile.open(local_archive_filename, "r:gz") as tarfil: - tarfil.extractall(dir) - - -url = f"{base_url}" -for func in functionals: - dir = f"{head_path}/{dir_header}/{func}" - os.makedirs(dir, exist_ok=True) - archive_filename = f"{func}_ch_pseudos.tgz" - archive_found = False - for entry in os.listdir(dir): - if entry == archive_filename: - archive_found = True - if not archive_found: - _download_extract_pseudo_archive(func) - - -# Check all the pseudos/core-wfc data files in the TOC dictionary -# and load/check all of them before proceeding. Note that this -# approach relies on there not being multiple instances of nodes -# with the same label. -for func in functionals: - gipaw_pseudo_dict = pseudo_data_dict[func]["gipaw_pseudos"] - core_wfc_dict = pseudo_data_dict[func]["core_wavefunction_data"] - core_hole_pseudo_dict = pseudo_data_dict[func]["core_hole_pseudos"] - main_path = f"{head_path}/{dir_header}/{func}" - core_wfc_dir = f"{main_path}/core_wfc_data" - gipaw_dir = f"{main_path}/gipaw_pseudos" - ch_pseudo_dir = f"{main_path}/ch_pseudos/star1s" - # First, check that the local directories contain what's in the pseudo_toc - for pseudo_dir, pseudo_dict in zip( - [gipaw_dir, core_wfc_dir, ch_pseudo_dir], - [gipaw_pseudo_dict, core_wfc_dict, core_hole_pseudo_dict], - ): - pseudo_toc_mismatch = os.listdir(pseudo_dir) != pseudo_dict.values() - - # Re-download the relevant archive if there is a mismatch - if pseudo_toc_mismatch: - _download_extract_pseudo_archive(func) - - _load_or_import_nodes_from_filenames( - in_dict=gipaw_pseudo_dict, - path=gipaw_dir, - ) - _load_or_import_nodes_from_filenames( - in_dict=core_wfc_dict, path=core_wfc_dir, core_wfc_data=True - ) - _load_or_import_nodes_from_filenames( - in_dict=core_hole_pseudo_dict["1s"], path=ch_pseudo_dir - ) - def get_builder(codes, structure, parameters, **kwargs): from copy import deepcopy protocol = parameters["workchain"]["protocol"] xas_parameters = parameters["xas"] - gipaw_pseudos_dict = pseudo_data_dict["pbe"]["gipaw_pseudos"] - ch_pseudos_dict = pseudo_data_dict["pbe"]["core_hole_pseudos"]["1s"] - core_wfc_data_dict = pseudo_data_dict["pbe"]["core_wavefunction_data"] - # set pseudo for element - pseudos = {} - core_wfc_data = {} core_hole_treatments = xas_parameters["core_hole_treatments"] elements_list = xas_parameters["elements_list"] supercell_min_parameter = xas_parameters["supercell_min_parameter"] - + pseudo_labels = xas_parameters["pseudo_labels"] + core_wfc_data_labels = xas_parameters["core_wfc_data_labels"] + pseudos = {} + # Convert the pseudo and core_wfc_data node labels into nodes: + core_wfc_data = {k: orm.load_node(v) for k, v in core_wfc_data_labels.items()} for element in elements_list: - gipaw_pseudo = orm.load_node(gipaw_pseudos_dict[element]) - ch_pseudo = orm.load_node(ch_pseudos_dict[element]) - core_wfc_node = orm.load_node(core_wfc_data_dict[element]) - pseudos[element] = {"gipaw": gipaw_pseudo, "core_hole": ch_pseudo} - core_wfc_data[element] = core_wfc_node + pseudos[element] = { + k: orm.load_node(v) for k, v in pseudo_labels[element].items() + } # TODO should we override the cutoff_wfc, cutoff_rho by the new pseudo? # In principle we should, if we know what that value is, but that would @@ -145,9 +44,6 @@ def get_builder(codes, structure, parameters, **kwargs): # True if xas_parameters.get("structure_type") == "molecule" else False # ) - # core_hole_treatment = xas_parameters["core_hole_treatment"] - # core_hole_treatments = {element: core_hole_treatment for element in elements_list} - structure_preparation_settings = { "supercell_min_parameter": orm.Float(supercell_min_parameter), # "is_molecule_input": orm.Bool(is_molecule_input), From cc963d47c19361473d8136441cb718bf62df83fa Mon Sep 17 00:00:00 2001 From: superstar54 Date: Mon, 29 Jan 2024 09:42:46 +0000 Subject: [PATCH 10/11] fix set_panel_value --- src/aiidalab_qe/plugins/xas/setting.py | 36 +++++++++++--------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/src/aiidalab_qe/plugins/xas/setting.py b/src/aiidalab_qe/plugins/xas/setting.py index 95fc7bc80..55b6ccb36 100644 --- a/src/aiidalab_qe/plugins/xas/setting.py +++ b/src/aiidalab_qe/plugins/xas/setting.py @@ -239,29 +239,23 @@ def get_panel_value(self): } return parameters - def set_panel_value(self): + def set_panel_value(self, input_dict): """Load a dictionary with the input parameters for the plugin.""" - # ch_pseudos = self.core_hole_pseudos - # structure = self.input_structure - # available_elements = [k for k in ch_pseudos] - # elements_to_select = sorted( - # [ - # kind.symbol - # for kind in structure.kinds - # if kind.symbol in available_elements - # ] - # ) - - # element_and_ch_treatment_options = {} - # for element in elements_to_select: - # if element in xch_elements: - # element_and_ch_treatment_options[element] = "xch_smear" - # else: - # element_and_ch_treatment_options[element] = "full" - - # self.element_and_ch_treatment_options = element_and_ch_treatment_options - self._update_element_select_panel() + # set selected elements and core-hole treatments + elements_list = input_dict.get("elements_list", []) + for entry in self.element_and_ch_treatment.children: + element = entry.children[0].description + if element in elements_list: + entry.children[0].value = True + entry.children[1].value = input_dict["core_hole_treatments"][element] + else: + entry.children[0].value = False + entry.children[1].value = "full" + # set supercell min parameter + self.supercell_min_parameter.value = input_dict.get( + "supercell_min_parameter", 8.0 + ) # self.structure_type.value = input_dict.get("structure_type", "crystal") @tl.observe("input_structure") From b0a80acb4dec0afd658525d6b531125888215eb9 Mon Sep 17 00:00:00 2001 From: superstar54 Date: Mon, 29 Jan 2024 09:43:28 +0000 Subject: [PATCH 11/11] add test for xas app's setting panel --- tests/test_plugins_xas.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/test_plugins_xas.py diff --git a/tests/test_plugins_xas.py b/tests/test_plugins_xas.py new file mode 100644 index 000000000..4b45fc43a --- /dev/null +++ b/tests/test_plugins_xas.py @@ -0,0 +1,37 @@ +import pytest + + +@pytest.mark.usefixtures("sssp") +def test_settings(submit_app_generator): + """Test the settings of the xas app.""" + app = submit_app_generator(properties=["xas"]) + configure_step = app.configure_step + # test get_panel_value + # select the first elmement + configure_step.settings["xas"].element_and_ch_treatment.children[0].children[ + 0 + ].value = True + configure_step.settings["xas"].supercell_min_parameter.value = 4.0 + parameters = configure_step.settings["xas"].get_panel_value() + assert parameters["core_hole_treatments"] == {"Si": "full"} + assert parameters["pseudo_labels"] == { + "Si": { + "gipaw": "Si.pbe-van_gipaw.UPF", + "core_hole": "Si.star1s-pbe-van_gipaw.UPF", + } + } + assert parameters["core_wfc_data_labels"] == {"Si": "Si.pbe-van_gipaw.dat"} + assert parameters["supercell_min_parameter"] == 4.0 + # test set_panel_value + # update the parameters + parameters["supercell_min_parameter"] = 5.0 + parameters["core_hole_treatments"] = {"Si": "xch_smear"} + configure_step.settings["xas"].set_panel_value(parameters) + assert configure_step.settings["xas"].supercell_min_parameter.value == 5.0 + assert ( + configure_step.settings["xas"] + .element_and_ch_treatment.children[0] + .children[1] + .value + == "xch_smear" + )