diff --git a/docs/source/development/architecture.rst b/docs/source/development/architecture.rst index 4daf5c908..5627b6c78 100644 --- a/docs/source/development/architecture.rst +++ b/docs/source/development/architecture.rst @@ -49,7 +49,7 @@ The dictionary has the following structure: "pseudo_family": "SSSP/1.3/PBEsol/efficiency", "kpoints_distance": 0.5, }, - "bands": {"kpath_2d": "hexagonal"}, + "bands": {}, "pdos": {...}, "plugin_1": {...}, "plugin_2": {...}, diff --git a/docs/source/development/plugin.rst b/docs/source/development/plugin.rst index 9176e69fd..5199e70cf 100644 --- a/docs/source/development/plugin.rst +++ b/docs/source/development/plugin.rst @@ -210,7 +210,7 @@ The `parameters` passed to the `get_builder` function has the following structur "pseudo_family": "SSSP/1.3/PBEsol/efficiency", "kpoints_distance": 0.5, }, - "bands": {"kpath_2d": "hexagonal"}, + "bands": {}, "pdos": {...}, "eos": {...}, "plugin_1": {...}, diff --git a/setup.cfg b/setup.cfg index 96b30824d..ae279ba08 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,6 +31,7 @@ install_requires = aiida-pseudo~=1.4 filelock~=3.8 importlib-resources~=5.2 + aiida-wannier90-workflows==2.3.0 python_requires = >=3.9 [options.packages.find] @@ -60,6 +61,9 @@ aiidalab_qe.properties = electronic_structure = aiidalab_qe.plugins.electronic_structure:electronic_structure xas = aiidalab_qe.plugins.xas:xas +aiida.workflows = + aiidalab_qe.bands_workchain = aiidalab_qe.plugins.bands.bands_workchain:BandsWorkChain + [aiidalab] title = Quantum ESPRESSO description = Perform Quantum ESPRESSO calculations diff --git a/src/aiidalab_qe/app/configuration/workflow.py b/src/aiidalab_qe/app/configuration/workflow.py index 0bf2938e8..fd7e288ec 100644 --- a/src/aiidalab_qe/app/configuration/workflow.py +++ b/src/aiidalab_qe/app/configuration/workflow.py @@ -154,12 +154,12 @@ def render(self): self.spin_type, ] ), + *self.property_children, ipw.HTML("""

Protocol

"""), - *self.property_children, ipw.HTML("Select the protocol:", layout=ipw.Layout(flex="1 1 auto")), self.protocol, ipw.HTML(""" diff --git a/src/aiidalab_qe/app/parameters/qeapp.yaml b/src/aiidalab_qe/app/parameters/qeapp.yaml index c00c0f1c1..1fba8c032 100644 --- a/src/aiidalab_qe/app/parameters/qeapp.yaml +++ b/src/aiidalab_qe/app/parameters/qeapp.yaml @@ -29,6 +29,8 @@ codes: code: dos-7.2@localhost projwfc: code: projwfc-7.2@localhost + projwfc_bands: + code: projwfc-7.2@localhost pw: code: pw-7.2@localhost pp: diff --git a/src/aiidalab_qe/plugins/bands/__init__.py b/src/aiidalab_qe/plugins/bands/__init__.py index 5a16f9316..54fa6842a 100644 --- a/src/aiidalab_qe/plugins/bands/__init__.py +++ b/src/aiidalab_qe/plugins/bands/__init__.py @@ -1,4 +1,5 @@ # from aiidalab_qe.bands.result import Result +from aiidalab_qe.app.submission.code.model import CodeModel from aiidalab_qe.common.panel import PanelOutline from .model import BandsModel @@ -19,6 +20,12 @@ class BandsOutline(PanelOutline): bands = { "outline": BandsOutline, "model": BandsModel, + "code": { + "projwfc_bands": CodeModel( + description="projwfc.x", + default_calc_job_plugin="quantumespresso.projwfc", + ), + }, "setting": Setting, "result": Result, "workchain": workchain_and_builder, diff --git a/src/aiidalab_qe/plugins/bands/bands_workchain.py b/src/aiidalab_qe/plugins/bands/bands_workchain.py new file mode 100644 index 000000000..d5401341d --- /dev/null +++ b/src/aiidalab_qe/plugins/bands/bands_workchain.py @@ -0,0 +1,382 @@ +import math + +import numpy as np + +from aiida import orm +from aiida.common import AttributeDict +from aiida.engine import WorkChain +from aiida.plugins import DataFactory, WorkflowFactory + +GAMMA = "\u0393" + +PwBandsWorkChain = WorkflowFactory("quantumespresso.pw.bands") +ProjwfcBandsWorkChain = WorkflowFactory("wannier90_workflows.projwfcbands") +KpointsData = DataFactory("core.array.kpoints") + + +def points_per_branch(vector_a, vector_b, reciprocal_cell, bands_kpoints_distance): + """function to calculate the number of points per branch depending on the kpoints_distance and the reciprocal cell""" + scaled_vector_a = np.array(vector_a) + scaled_vector_b = np.array(vector_b) + reciprocal_vector_a = scaled_vector_a.dot(reciprocal_cell) + reciprocal_vector_b = scaled_vector_b.dot(reciprocal_cell) + distance = np.linalg.norm(reciprocal_vector_a - reciprocal_vector_b) + return max( + 2, int(np.round(distance / bands_kpoints_distance)) + ) # at least two points for each segment, including both endpoints explicitly + + +def calculate_bands_kpoints_distance(kpoints_distance): + """function to calculate the bands_kpoints_distance depending on the kpoints_distance""" + if kpoints_distance >= 0.5: + return 0.1 + elif 0.15 < kpoints_distance < 0.5: + return 0.025 + else: + return 0.015 + + +def generate_kpath_1d(structure, kpoints_distance): + """Return a kpoints object for one dimensional systems (from Gamma to X) + The number of kpoints is calculated based on the kpoints_distance (as in the PwBandsWorkChain protocol) + """ + kpoints = KpointsData() + kpoints.set_cell_from_structure(structure) + reciprocal_cell = kpoints.reciprocal_cell + bands_kpoints_distance = calculate_bands_kpoints_distance(kpoints_distance) + + # Number of points per branch + num_points_per_branch = points_per_branch( + [0.0, 0.0, 0.0], + [0.5, 0.0, 0.0], + reciprocal_cell, + bands_kpoints_distance, + ) + # Generate the kpoints + points = np.linspace( + start=[0.0, 0.0, 0.0], + stop=[0.5, 0.0, 0.0], + endpoint=True, + num=num_points_per_branch, + ) + kpoints.set_kpoints(points.tolist()) + kpoints.labels = [[0, GAMMA], [len(points) - 1, "X"]] + return kpoints + + +def generate_kpath_2d(structure, kpoints_distance, kpath_2d): + """Return a kpoints object for two dimensional systems based on the selected 2D symmetry path + The number of kpoints is calculated based on the kpoints_distance (as in the PwBandsWorkChain protocol) + The 2D symmetry paths are defined as in The Journal of Physical Chemistry Letters 2022 13 (50), 11581-11594 (https://pubs.acs.org/doi/10.1021/acs.jpclett.2c02972) + """ + kpoints = KpointsData() + kpoints.set_cell_from_structure(structure) + reciprocal_cell = kpoints.reciprocal_cell + bands_kpoints_distance = calculate_bands_kpoints_distance(kpoints_distance) + + # dictionary with the 2D symmetry paths + selected_paths = { + "hexagonal": { + "path": [ + [0.0, 0.0, 0.0], + [0.5, 0.0, 0.0], + [0.33333, 0.33333, 0.0], + [1.0, 0.0, 0.0], + ], + "labels": [GAMMA, "M", "K", GAMMA], + }, + "square": { + "path": [ + [0.0, 0.0, 0.0], + [0.5, 0.0, 0.0], + [0.5, 0.5, 0.0], + [1.0, 0.0, 0.0], + ], + "labels": [GAMMA, "X", "M", GAMMA], + }, + "rectangular": { + "path": [ + [0.0, 0.0, 0.0], + [0.5, 0.0, 0.0], + [0.5, 0.5, 0.0], + [0.0, 0.5, 0.0], + [1.0, 0.0, 0.0], + ], + "labels": [GAMMA, "X", "S", "Y", GAMMA], + }, + } + # if the selected path is centered_rectangular or oblique, the path is calculated based on the reciprocal cell + if kpath_2d in ["centered_rectangular", "oblique"]: + a1 = reciprocal_cell[0] + a2 = reciprocal_cell[1] + norm_a1 = np.linalg.norm(a1) + norm_a2 = np.linalg.norm(a2) + cos_gamma = ( + a1.dot(a2) / (norm_a1 * norm_a2) + ) # Angle between a1 and a2 # like in https://pubs.acs.org/doi/10.1021/acs.jpclett.2c02972 + gamma = np.arccos(cos_gamma) + eta = (1 - (norm_a1 / norm_a2) * cos_gamma) / (2 * np.power(np.sin(gamma), 2)) + nu = 0.5 - (eta * norm_a2 * cos_gamma) / norm_a1 + selected_paths["centered_rectangular"] = { + "path": [ + [0.0, 0.0, 0.0], + [0.5, 0.0, 0.0], + [1 - eta, nu, 0], + [0.5, 0.5, 0.0], + [eta, 1 - nu, 0.0], + [1.0, 0.0, 0.0], + ], + "labels": [GAMMA, "X", "H_1", "C", "H", GAMMA], + } + selected_paths["oblique"] = { + "path": [ + [0.0, 0.0, 0.0], + [0.5, 0.0, 0.0], + [1 - eta, nu, 0], + [0.5, 0.5, 0.0], + [eta, 1 - nu, 0.0], + [0.0, 0.5, 0.0], + [1.0, 0.0, 0.0], + ], + "labels": [GAMMA, "X", "H_1", "C", "H", "Y", GAMMA], + } + path = selected_paths[kpath_2d]["path"] + labels = selected_paths[kpath_2d]["labels"] + branches = zip(path[:-1], path[1:]) + + all_kpoints = [] # List to hold all k-points + label_map = [] # List to hold labels and their corresponding k-point indices + + # Calculate the number of points per branch and generate the kpoints + index_offset = 0 # Start index for each segment + for (start, end), label_start, _ in zip(branches, labels[:-1], labels[1:]): + num_points_per_branch = points_per_branch( + start, end, reciprocal_cell, bands_kpoints_distance + ) + # Exclude endpoint except for the last segment to prevent duplication + points = np.linspace(start, end, num=num_points_per_branch, endpoint=False) + all_kpoints.extend(points) + label_map.append( + (index_offset, label_start) + ) # Label for the start of the segment + index_offset += len(points) + + # Include the last point and its label + all_kpoints.append(path[-1]) + label_map.append((index_offset, labels[-1])) # Label for the last point + + # Set the kpoints and their labels in KpointsData + kpoints.set_kpoints(all_kpoints) + kpoints.labels = label_map + + return kpoints + + +def determine_symmetry_path(structure): + # Tolerance for checking equality + cell_lengths = structure.cell_lengths + cell_angles = structure.cell_angles + tolerance = 1e-3 + + # Define symmetry conditions and their corresponding types in a dictionary + symmetry_conditions = { + ( + math.isclose(cell_lengths[0], cell_lengths[1], abs_tol=tolerance) + and math.isclose(cell_angles[2], 120.0, abs_tol=tolerance) + ): "hexagonal", + ( + math.isclose(cell_lengths[0], cell_lengths[1], abs_tol=tolerance) + and math.isclose(cell_angles[2], 90.0, abs_tol=tolerance) + ): "square", + ( + not math.isclose(cell_lengths[0], cell_lengths[1], abs_tol=tolerance) + and math.isclose(cell_angles[2], 90.0, abs_tol=tolerance) + ): "rectangular", + ( + math.isclose( + cell_lengths[1] * math.cos(math.radians(cell_angles[2])), + cell_lengths[0] / 2, + abs_tol=tolerance, + ) + ): "rectangular_centered", + ( + not math.isclose(cell_lengths[0], cell_lengths[1], abs_tol=tolerance) + and not math.isclose(cell_angles[2], 90.0, abs_tol=tolerance) + ): "oblique", + } + + # Check for symmetry type based on conditions + for condition, symmetry_type in symmetry_conditions.items(): + if condition: + return symmetry_type + + raise ValueError("Invalid symmetry type") + + +class BandsWorkChain(WorkChain): + "Workchain to compute the electronic band structure" + + label = "bands" + + @classmethod + def define(cls, spec): + super().define(spec) + + spec.input("structure", valid_type=orm.StructureData) + spec.expose_inputs( + PwBandsWorkChain, + namespace="bands", + exclude=["structure", "relax"], + namespace_options={ + "required": False, + "populate_defaults": False, + "help": "Inputs for the `PwBandsWorkChain`, simulation mode normal.", + }, + ) + spec.expose_inputs( + ProjwfcBandsWorkChain, + namespace="bands_projwfc", + exclude=["structure", "relax"], + namespace_options={ + "required": False, + "populate_defaults": False, + "help": "Inputs for the `ProjwfcBandsWorkChain`, simulation mode fat_bands.", + }, + ) + + spec.expose_outputs( + PwBandsWorkChain, + namespace="bands", + namespace_options={ + "required": False, + "help": "Outputs of the `PwBandsWorkChain`.", + }, + ) + spec.expose_outputs( + ProjwfcBandsWorkChain, + namespace="bands_projwfc", + namespace_options={ + "required": False, + "help": "Outputs of the `PwBandsWorkChain`.", + }, + ) + + spec.outline(cls.setup, cls.run_bands, cls.results) + + spec.exit_code( + 400, "ERROR_WORKCHAIN_FAILED", message="The workchain bands failed." + ) + + @classmethod + def get_builder_from_protocol( + cls, + pw_code, + projwfc_code, + structure, + simulation_mode="normal", + protocol=None, + overrides=None, + **kwargs, + ): + """Return a BandsWorkChain builder prepopulated with inputs following the specified protocol + + :param structure: the ``StructureData`` instance to use. + :param pw_code: the ``Code`` instance configured for the ``quantumespresso.pw`` plugin. + :param protocol: protocol to use, if not specified, the default will be used. + :param projwfc_code: the ``Code`` instance configured for the ``quantumespresso.projwfc`` plugin. + :param simulation_mode: hat type of simulation to run normal band or fat bands. + + """ + + builder = cls.get_builder() + + if simulation_mode == "normal": + builder_bands = PwBandsWorkChain.get_builder_from_protocol( + pw_code, structure, protocol, overrides=overrides, **kwargs + ) + builder.pop("bands_projwfc", None) + builder_bands.pop("relax", None) + builder_bands.pop("structure", None) + builder.bands = builder_bands + + elif simulation_mode == "fat_bands": + builder_bands_projwfc = ProjwfcBandsWorkChain.get_builder_from_protocol( + pw_code, + projwfc_code, + structure, + protocol=protocol, + overrides=overrides, + **kwargs, + ) + builder.pop("bands", None) + builder_bands_projwfc.pop("relax", None) + builder_bands_projwfc.pop("structure", None) + builder.bands_projwfc = builder_bands_projwfc + + else: + raise ValueError(f"Unknown simulation_mode: {simulation_mode}") + + # Handle periodic boundary conditions (PBC) + if structure.pbc != (True, True, True): + kpoints_distance = overrides.get("scf", {}).get( + "kpoints_distance", + builder.get("bands", {}).get("scf", {}).get("kpoints_distance"), + ) + kpoints = None + if structure.pbc == (True, False, False): + kpoints = generate_kpath_1d(structure, kpoints_distance) + elif structure.pbc == (True, True, False): + kpoints = generate_kpath_2d( + structure=structure, + kpoints_distance=kpoints_distance, + kpath_2d=determine_symmetry_path(structure), + ) + + if simulation_mode == "normal": + builder.bands.pop("bands_kpoints_distance", None) + builder.bands.update({"bands_kpoints": kpoints}) + elif simulation_mode == "fat_bands": + builder.bands_projwfc.pop("bands_kpoints_distance", None) + builder.bands_projwfc.update({"bands_kpoints": kpoints}) + + builder.structure = structure + return builder + + def setup(self): + """Define the current workchain""" + self.ctx.current_structure = self.inputs.structure + if "bands" in self.inputs: + self.ctx.key = "bands" + self.ctx.workchain = PwBandsWorkChain + elif "bands_projwfc" in self.inputs: + self.ctx.key = "bands_projwfc" + self.ctx.workchain = ProjwfcBandsWorkChain + else: + self.report("No bands workchain specified") + return self.exit_codes.ERROR_WORKCHAIN_FAILED + + def run_bands(self): + """Run the bands workchain""" + inputs = AttributeDict( + self.exposed_inputs(self.ctx.workchain, namespace=self.ctx.key) + ) + inputs.metadata.call_link_label = self.ctx.key + inputs.structure = self.ctx.current_structure + future = self.submit(self.ctx.workchain, **inputs) + self.report(f"submitting `WorkChain` ") + self.to_context(**{self.ctx.key: future}) + + def results(self): + """Attach the bands results""" + workchain = self.ctx[self.ctx.key] + + if not workchain.is_finished_ok: + self.report("Bands workchain failed") + return self.exit_codes.ERROR_WORKCHAIN_FAILED + else: + self.out_many( + self.exposed_outputs( + self.ctx[self.ctx.key], self.ctx.workchain, namespace=self.ctx.key + ) + ) + self.report("Bands workchain completed successfully") diff --git a/src/aiidalab_qe/plugins/bands/model.py b/src/aiidalab_qe/plugins/bands/model.py index c9c8ad851..1f5b57b26 100644 --- a/src/aiidalab_qe/plugins/bands/model.py +++ b/src/aiidalab_qe/plugins/bands/model.py @@ -6,18 +6,13 @@ class BandsModel(SettingsModel): """Model for the band structure plugin.""" - kpath_2d = tl.Unicode("hexagonal") + projwfc_bands = tl.Bool(False) def get_model_state(self): - return { - "kpath_2d": self.kpath_2d, - } + return {"projwfc_bands": self.projwfc_bands} def set_model_state(self, parameters: dict): - self.kpath_2d = parameters.get( - "kpath_2d", - self.traits()["kpath_2d"].default_value, - ) + self.projwfc_bands = parameters.get("projwfc_bands", False) def reset(self): - self.kpath_2d = self.traits()["kpath_2d"].default_value + self.kpath_2d = False diff --git a/src/aiidalab_qe/plugins/bands/result.py b/src/aiidalab_qe/plugins/bands/result.py index 03b7630cd..7ca4b206c 100644 --- a/src/aiidalab_qe/plugins/bands/result.py +++ b/src/aiidalab_qe/plugins/bands/result.py @@ -14,11 +14,22 @@ def __init__(self, node=None, **kwargs): super().__init__(node=node, **kwargs) def _update_view(self): - # Check if the workchain has the outputs - try: - bands_node = self.node.outputs.bands - except AttributeError: - bands_node = None + # Initialize bands_node to None by default + bands_node = None + + # Check if the workchain has the 'bands' output + if hasattr(self.node.outputs, "bands"): + bands_output = self.node.outputs.bands + + # Check for 'bands' or 'bands_projwfc' attributes within 'bands' output + if hasattr(bands_output, "bands"): + bands_node = bands_output.bands + elif hasattr(bands_output, "bands_projwfc"): + bands_node = bands_output.bands_projwfc + else: + # If neither 'bands' nor 'bands_projwfc' exist, use 'bands_output' itself + # This is the case for compatibility with older versions of the plugin + bands_node = bands_output _bands_plot_view = BandPdosWidget(bands=bands_node) self.children = [ diff --git a/src/aiidalab_qe/plugins/bands/setting.py b/src/aiidalab_qe/plugins/bands/setting.py index 341011356..abf4cdc86 100644 --- a/src/aiidalab_qe/plugins/bands/setting.py +++ b/src/aiidalab_qe/plugins/bands/setting.py @@ -13,49 +13,35 @@ def render(self): if self.rendered: return - self.settings_title = ipw.HTML( - """
-

Settings

""" - ) - self.protocol = ipw.ToggleButtons( - options=["fast", "moderate", "precise"], + self.projwfc_bands = ipw.Checkbox( + description="Fat bands calculation", + style={"description_width": "initial"}, ) ipw.link( - (self._config_model.workchain, "protocol"), - (self.protocol, "value"), - ) - - self.kpath_2d_help = ipw.HTML( - """
- If your system has periodicity xy. Please select one of the five 2D Bravais lattices corresponding to your system. -
""" - ) - self.kpath_2d = ipw.Dropdown( - description="Lattice:", - options=[ - ("Hexagonal", "hexagonal"), - ("Square", "square"), - ("Rectangular", "rectangular"), - ("Centered Rectangular", "centered_rectangular"), - ("Oblique", "oblique"), - ], - ) - ipw.link( - (self._model, "kpath_2d"), - (self.kpath_2d, "value"), + (self._model, "projwfc_bands"), + (self.projwfc_bands, "value"), ) self.children = [ - self.settings_title, - self.kpath_2d_help, - self.kpath_2d, + ipw.HTML(""" +
+

Settings

+
+ """), ipw.HTML("""
The band structure workflow will automatically detect the default path in reciprocal space using the SeeK-path tool. +

+ Fat Bands is a band structure plot that includes the angular + momentum contributions from specific atoms or orbitals to each + energy band. The thickness of the bands represents the strength + of these contributions, providing insight into the electronic + structure.
"""), + self.projwfc_bands, ] self.rendered = True diff --git a/src/aiidalab_qe/plugins/bands/workchain.py b/src/aiidalab_qe/plugins/bands/workchain.py index c3cc5eb78..863a7dc0b 100644 --- a/src/aiidalab_qe/plugins/bands/workchain.py +++ b/src/aiidalab_qe/plugins/bands/workchain.py @@ -1,180 +1,48 @@ -import numpy as np - -from aiida.plugins import DataFactory, WorkflowFactory +from aiida.plugins import WorkflowFactory from aiida_quantumespresso.common.types import ElectronicType, SpinType from aiidalab_qe.plugins.utils import set_component_resources -GAMMA = "\u0393" - -PwBandsWorkChain = WorkflowFactory("quantumespresso.pw.bands") -KpointsData = DataFactory("core.array.kpoints") - +BandsWorkChain = WorkflowFactory("aiidalab_qe.bands_workchain") +# from .bands_workchain import BandsWorkChain -def points_per_branch(vector_a, vector_b, reciprocal_cell, bands_kpoints_distance): - """function to calculate the number of points per branch depending on the kpoints_distance and the reciprocal cell""" - scaled_vector_a = np.array(vector_a) - scaled_vector_b = np.array(vector_b) - reciprocal_vector_a = scaled_vector_a.dot(reciprocal_cell) - reciprocal_vector_b = scaled_vector_b.dot(reciprocal_cell) - distance = np.linalg.norm(reciprocal_vector_a - reciprocal_vector_b) - return max( - 2, int(np.round(distance / bands_kpoints_distance)) - ) # at least two points for each segment, including both endpoints explicitly - -def calculate_bands_kpoints_distance(kpoints_distance): - """function to calculate the bands_kpoints_distance depending on the kpoints_distance""" - if kpoints_distance >= 0.5: - return 0.1 - elif 0.15 < kpoints_distance < 0.5: - return 0.025 - else: - return 0.015 - - -def generate_kpath_1d(structure, kpoints_distance): - """Return a kpoints object for one dimensional systems (from Gamma to X) - The number of kpoints is calculated based on the kpoints_distance (as in the PwBandsWorkChain protocol) - """ - kpoints = KpointsData() - kpoints.set_cell_from_structure(structure) - reciprocal_cell = kpoints.reciprocal_cell - bands_kpoints_distance = calculate_bands_kpoints_distance(kpoints_distance) - - # Number of points per branch - num_points_per_branch = points_per_branch( - [0.0, 0.0, 0.0], - [0.5, 0.0, 0.0], - reciprocal_cell, - bands_kpoints_distance, - ) - # Generate the kpoints - points = np.linspace( - start=[0.0, 0.0, 0.0], - stop=[0.5, 0.0, 0.0], - endpoint=True, - num=num_points_per_branch, - ) - kpoints.set_kpoints(points.tolist()) - kpoints.labels = [[0, GAMMA], [len(points) - 1, "X"]] - return kpoints - - -def generate_kpath_2d(structure, kpoints_distance, kpath_2d): - """Return a kpoints object for two dimensional systems based on the selected 2D symmetry path - The number of kpoints is calculated based on the kpoints_distance (as in the PwBandsWorkChain protocol) - The 2D symmetry paths are defined as in The Journal of Physical Chemistry Letters 2022 13 (50), 11581-11594 (https://pubs.acs.org/doi/10.1021/acs.jpclett.2c02972) - """ - kpoints = KpointsData() - kpoints.set_cell_from_structure(structure) - reciprocal_cell = kpoints.reciprocal_cell - bands_kpoints_distance = calculate_bands_kpoints_distance(kpoints_distance) - - # dictionary with the 2D symmetry paths - selected_paths = { - "hexagonal": { - "path": [ - [0.0, 0.0, 0.0], - [0.5, 0.0, 0.0], - [0.33333, 0.33333, 0.0], - [1.0, 0.0, 0.0], - ], - "labels": [GAMMA, "M", "K", GAMMA], - }, - "square": { - "path": [ - [0.0, 0.0, 0.0], - [0.5, 0.0, 0.0], - [0.5, 0.5, 0.0], - [1.0, 0.0, 0.0], - ], - "labels": [GAMMA, "X", "M", GAMMA], - }, - "rectangular": { - "path": [ - [0.0, 0.0, 0.0], - [0.5, 0.0, 0.0], - [0.5, 0.5, 0.0], - [0.0, 0.5, 0.0], - [1.0, 0.0, 0.0], - ], - "labels": [GAMMA, "X", "S", "Y", GAMMA], - }, - } - # if the selected path is centered_rectangular or oblique, the path is calculated based on the reciprocal cell - if kpath_2d in ["centered_rectangular", "oblique"]: - a1 = reciprocal_cell[0] - a2 = reciprocal_cell[1] - norm_a1 = np.linalg.norm(a1) - norm_a2 = np.linalg.norm(a2) - cos_gamma = ( - a1.dot(a2) / (norm_a1 * norm_a2) - ) # Angle between a1 and a2 # like in https://pubs.acs.org/doi/10.1021/acs.jpclett.2c02972 - gamma = np.arccos(cos_gamma) - eta = (1 - (norm_a1 / norm_a2) * cos_gamma) / (2 * np.power(np.sin(gamma), 2)) - nu = 0.5 - (eta * norm_a2 * cos_gamma) / norm_a1 - selected_paths["centered_rectangular"] = { - "path": [ - [0.0, 0.0, 0.0], - [0.5, 0.0, 0.0], - [1 - eta, nu, 0], - [0.5, 0.5, 0.0], - [eta, 1 - nu, 0.0], - [1.0, 0.0, 0.0], - ], - "labels": [GAMMA, "X", "H_1", "C", "H", GAMMA], - } - selected_paths["oblique"] = { - "path": [ - [0.0, 0.0, 0.0], - [0.5, 0.0, 0.0], - [1 - eta, nu, 0], - [0.5, 0.5, 0.0], - [eta, 1 - nu, 0.0], - [0.0, 0.5, 0.0], - [1.0, 0.0, 0.0], - ], - "labels": [GAMMA, "X", "H_1", "C", "H", "Y", GAMMA], - } - path = selected_paths[kpath_2d]["path"] - labels = selected_paths[kpath_2d]["labels"] - branches = zip(path[:-1], path[1:]) - - all_kpoints = [] # List to hold all k-points - label_map = [] # List to hold labels and their corresponding k-point indices - - # Calculate the number of points per branch and generate the kpoints - index_offset = 0 # Start index for each segment - for (start, end), label_start, _label_end in zip(branches, labels[:-1], labels[1:]): - num_points_per_branch = points_per_branch( - start, end, reciprocal_cell, bands_kpoints_distance +def check_codes(pw_code, projwfc_code): + """Check that the codes are installed on the same computer.""" + if ( + not any( + [ + pw_code is None, + projwfc_code is None, + ] + ) + and len( + { + pw_code.computer.pk, + projwfc_code.computer.pk, + } + ) + != 1 + ): + raise ValueError( + "All selected codes must be installed on the same computer. This is because the " + "BandsWorkChain calculations rely on large files that are not retrieved by AiiDA." ) - # Exclude endpoint except for the last segment to prevent duplication - points = np.linspace(start, end, num=num_points_per_branch, endpoint=False) - all_kpoints.extend(points) - label_map.append( - (index_offset, label_start) - ) # Label for the start of the segment - index_offset += len(points) - - # Include the last point and its label - all_kpoints.append(path[-1]) - label_map.append((index_offset, labels[-1])) # Label for the last point - - # Set the kpoints and their labels in KpointsData - kpoints.set_kpoints(all_kpoints) - kpoints.labels = label_map - - return kpoints def update_resources(builder, codes): - set_component_resources(builder.scf.pw, codes.get("pw")) - set_component_resources(builder.bands.pw, codes.get("pw")) + if "bands" in builder: + set_component_resources(builder.bands.scf.pw, codes.get("pw")) + set_component_resources(builder.bands.bands.pw, codes.get("pw")) + elif "bands_projwfc" in builder: + set_component_resources(builder.bands_projwfc.scf.pw, codes.get("pw")) + set_component_resources(builder.bands_projwfc.bands.pw, codes.get("pw")) + set_component_resources( + builder.bands_projwfc.projwfc.projwfc, codes.get("projwfc_bands") + ) def get_builder(codes, structure, parameters, **kwargs): - """Get a builder for the PwBandsWorkChain.""" + """Get a builder for the BandsWorkChain.""" from copy import deepcopy pw_code = codes.get("pw")["code"] @@ -188,14 +56,25 @@ def get_builder(codes, structure, parameters, **kwargs): bands_overrides.pop("kpoints_distance", None) bands_overrides["pw"]["parameters"]["SYSTEM"].pop("smearing", None) bands_overrides["pw"]["parameters"]["SYSTEM"].pop("degauss", None) + + check_codes(pw_code, codes.get("projwfc_bands")["code"]) + overrides = { "scf": scf_overrides, "bands": bands_overrides, "relax": relax_overrides, } - bands = PwBandsWorkChain.get_builder_from_protocol( - code=pw_code, + + if parameters["bands"]["projwfc_bands"]: + simulation_mode = "fat_bands" + else: + simulation_mode = "normal" + + bands_builder = BandsWorkChain.get_builder_from_protocol( + pw_code=pw_code, + projwfc_code=codes.get("projwfc_bands")["code"], structure=structure, + simulation_mode=simulation_mode, protocol=protocol, electronic_type=ElectronicType(parameters["workchain"]["electronic_type"]), spin_type=SpinType(parameters["workchain"]["spin_type"]), @@ -203,44 +82,18 @@ def get_builder(codes, structure, parameters, **kwargs): overrides=overrides, **kwargs, ) + update_resources(bands_builder, codes) - if structure.pbc != (True, True, True): - kpoints_distance = parameters["advanced"]["kpoints_distance"] - if structure.pbc == (True, False, False): - kpoints = generate_kpath_1d(structure, kpoints_distance) - elif structure.pbc == (True, True, False): - kpoints = generate_kpath_2d( - structure, kpoints_distance, parameters["bands"]["kpath_2d"] - ) - bands.pop("bands_kpoints_distance") - bands.update({"bands_kpoints": kpoints}) - - # pop the inputs that are excluded from the expose_inputs - bands.pop("relax") - bands.pop("structure", None) - bands.pop("clean_workdir", None) - # update resources - update_resources(bands, codes) - - if scf_overrides["pw"]["parameters"]["SYSTEM"].get("tot_magnetization") is not None: - bands.scf["pw"]["parameters"]["SYSTEM"].pop("starting_magnetization", None) - bands.bands["pw"]["parameters"]["SYSTEM"].pop("starting_magnetization", None) - - return bands + return bands_builder def update_inputs(inputs, ctx): """Update the inputs using context.""" inputs.structure = ctx.current_structure - inputs.scf.pw.parameters = inputs.scf.pw.parameters.get_dict() - if ctx.current_number_of_bands: - inputs.scf.pw.parameters.setdefault("SYSTEM", {}).setdefault( - "nbnd", ctx.current_number_of_bands - ) workchain_and_builder = { - "workchain": PwBandsWorkChain, + "workchain": BandsWorkChain, "exclude": ("structure", "relax"), "get_builder": get_builder, "update_inputs": update_inputs, diff --git a/src/aiidalab_qe/plugins/electronic_structure/result.py b/src/aiidalab_qe/plugins/electronic_structure/result.py index 1142ae450..890e92c3a 100644 --- a/src/aiidalab_qe/plugins/electronic_structure/result.py +++ b/src/aiidalab_qe/plugins/electronic_structure/result.py @@ -19,10 +19,23 @@ def _update_view(self): except AttributeError: pdos_node = None - try: - bands_node = self.node.outputs.bands - except AttributeError: - bands_node = None + # Initialize bands_node to None by default + bands_node = None + + # Check if the workchain has the 'bands' output + if hasattr(self.node.outputs, "bands"): + bands_output = self.node.outputs.bands + + # Check for 'bands' or 'bands_projwfc' attributes within 'bands' output + if hasattr(bands_output, "bands"): + bands_node = bands_output.bands + elif hasattr(bands_output, "bands_projwfc"): + bands_node = bands_output.bands_projwfc + else: + # If neither 'bands' nor 'bands_projwfc' exist, use 'bands_output' itself + # This is the case for compatibility with older versions of the plugin + bands_node = bands_output + _bands_dos_widget = BandPdosWidget(bands=bands_node, pdos=pdos_node) # update the electronic structure tab self.children = [_bands_dos_widget] diff --git a/tests/conftest.py b/tests/conftest.py index 64a0ff8fb..af07e8619 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,16 @@ pytest_plugins = ["aiida.manage.tests.pytest_fixtures"] +ELEMENTS = [ + "H", + "Li", + "O", + "S", + "Si", + "Co", + "Mo", +] + @pytest.fixture def fixture_localhost(aiida_localhost): @@ -92,6 +102,14 @@ def _generate_structure_data(name="silicon", pbc=(True, True, True)): for site in sites: structure.append_atom(position=site[2], symbols=site[0], name=site[1]) + + elif name == "MoS2": + cell = [[3.1922, 0, 0], [-1.5961, 2.7646, 0], [0, 0, 13.3783]] + structure = orm.StructureData(cell=cell) + structure.append_atom(position=(-0.0, 1.84, 10.03), symbols="Mo") + structure.append_atom(position=(1.6, 0.92, 8.47), symbols="S") + structure.append_atom(position=(1.6, 0.92, 11.6), symbols="S") + structure.pbc = pbc return structure @@ -195,7 +213,7 @@ def sssp(generate_upf_data): with tempfile.TemporaryDirectory() as d: dirpath = pathlib.Path(d) - for element in ("H", "Li", "O", "Si", "Co"): + for element in ELEMENTS: upf = generate_upf_data(element) filename = dirpath / f"{element}.upf" @@ -227,7 +245,7 @@ def pseudodojo(generate_upf_data): with tempfile.TemporaryDirectory() as d: dirpath = pathlib.Path(d) - for element in ("H", "Li", "O", "Si", "Co"): + for element in ELEMENTS: upf = generate_upf_data(element) filename = dirpath / f"{element}.upf" @@ -303,6 +321,16 @@ def projwfc_code(aiida_local_code_factory): ) +@pytest.fixture +def projwfc_bands_code(aiida_local_code_factory): + """Return a `Code` configured for the projwfc.x executable.""" + return aiida_local_code_factory( + label="projwfc_bands", + executable="bash", + entry_point="quantumespresso.projwfc", + ) + + @pytest.fixture() def workchain_settings_generator(): """Return a function that generates a workchain settings dictionary.""" @@ -332,7 +360,7 @@ def _smearing_settings_generator(**kwargs): @pytest.fixture -def app(pw_code, dos_code, projwfc_code): +def app(pw_code, dos_code, projwfc_code, projwfc_bands_code): from aiidalab_qe.app.main import App app = App(qe_auto_setup=False) @@ -348,14 +376,17 @@ def app(pw_code, dos_code, projwfc_code): # set up codes app.submit_model.get_code("pdos", "dos").activate() app.submit_model.get_code("pdos", "projwfc").activate() + app.submit_model.get_code("bands", "projwfc_bands").activate() app.submit_model.code_widgets["pw"].code_selection.refresh() app.submit_model.code_widgets["dos"].code_selection.refresh() app.submit_model.code_widgets["projwfc"].code_selection.refresh() + app.submit_model.code_widgets["projwfc_bands"].code_selection.refresh() app.submit_model.code_widgets["pw"].value = pw_code.uuid app.submit_model.code_widgets["dos"].value = dos_code.uuid app.submit_model.code_widgets["projwfc"].value = projwfc_code.uuid + app.submit_model.code_widgets["projwfc_bands"].value = projwfc_bands_code.uuid # TODO overrides app defaults - check! app.submit_model.set_selected_codes( @@ -363,6 +394,7 @@ def app(pw_code, dos_code, projwfc_code): "pw": {"code": pw_code.label}, "dos": {"code": dos_code.label}, "projwfc": {"code": projwfc_code.label}, + "projwfc_bands": {"code": projwfc_bands_code.label}, } ) @@ -591,17 +623,17 @@ def generate_bands_workchain( """Generate an instance of a the WorkChain.""" def _generate_bands_workchain(structure): - from copy import deepcopy - from aiida import engine from aiida.orm import Dict - from aiida_quantumespresso.workflows.pw.bands import PwBandsWorkChain + from aiidalab_qe.plugins.bands.bands_workchain import BandsWorkChain pseudo_family = f"SSSP/{SSSP_VERSION}/PBEsol/efficiency" inputs = { - "code": fixture_code("quantumespresso.pw"), + "pw_code": fixture_code("quantumespresso.pw"), + "projwfc_code": fixture_code("quantumespresso.projwfc"), "structure": structure, + "simulation_mode": "normal", "overrides": { "scf": { "pseudo_family": pseudo_family, @@ -619,20 +651,31 @@ def _generate_bands_workchain(structure): }, }, } - builder = PwBandsWorkChain.get_builder_from_protocol(**inputs) + builder = BandsWorkChain.get_builder_from_protocol(**inputs) inputs = builder._inputs() - inputs["relax"]["base_final_scf"] = deepcopy(inputs["relax"]["base"]) - wkchain = generate_workchain(PwBandsWorkChain, inputs) + wkchain = generate_workchain(BandsWorkChain, inputs) wkchain.setup() # run bands and return the process - output_parameters = Dict(dict={"fermi_energy": 2.0}) - output_parameters.store() - wkchain.out("scf_parameters", output_parameters) - wkchain.out("band_parameters", output_parameters) + fermi_dict = Dict(dict={"fermi_energy": 2.0}) + fermi_dict.store() + output_parameters = { + "bands": { + "scf_parameters": fermi_dict, + "band_parameters": fermi_dict, + } + } + + wkchain.out( + "bands.scf_parameters", output_parameters["bands"]["scf_parameters"] + ) + wkchain.out( + "bands.band_parameters", output_parameters["bands"]["band_parameters"] + ) + # band_structure = generate_bands_data() band_structure.store() - wkchain.out("band_structure", band_structure) + wkchain.out("bands.band_structure", band_structure) wkchain.update_outputs() # bands_node = wkchain.node @@ -649,6 +692,7 @@ def generate_qeapp_workchain( generate_workchain, generate_pdos_workchain, generate_bands_workchain, + fixture_code, ): """Generate an instance of the WorkChain.""" @@ -665,6 +709,7 @@ def _generate_qeapp_workchain( ): from copy import deepcopy + from aiida.orm import Dict from aiida.orm.utils.serialize import serialize from aiidalab_qe.workflows import QeAppWorkChain @@ -718,12 +763,36 @@ def _generate_qeapp_workchain( app.submit_model.code_widgets["pw"].num_cpus.value = 4 parameters = app.submit_model._get_submission_parameters() builder = app.submit_model._create_builder(parameters) + inputs = builder._inputs() inputs["relax"]["base_final_scf"] = deepcopy(inputs["relax"]["base"]) + + # Setting up inputs for bands_projwfc + inputs["bands"]["bands_projwfc"]["scf"]["pw"] = deepcopy( + inputs["bands"]["bands"]["scf"]["pw"] + ) + inputs["bands"]["bands_projwfc"]["bands"]["pw"] = deepcopy( + inputs["bands"]["bands"]["bands"]["pw"] + ) + inputs["bands"]["bands_projwfc"]["bands"]["pw"]["code"] = inputs["bands"][ + "bands" + ]["bands"]["pw"]["code"] + inputs["bands"]["bands_projwfc"]["scf"]["pw"]["code"] = inputs["bands"][ + "bands" + ]["scf"]["pw"]["code"] + + inputs["bands"]["bands_projwfc"]["projwfc"]["projwfc"]["code"] = fixture_code( + "quantumespresso.projwfc" + ) + inputs["bands"]["bands_projwfc"]["projwfc"]["projwfc"]["parameters"] = Dict( + {"PROJWFC": {"DeltaE": 0.01}} + ).store() + if run_bands: inputs["properties"].append("bands") if run_pdos: inputs["properties"].append("pdos") + workchain = generate_workchain(QeAppWorkChain, inputs) workchain.setup() @@ -742,13 +811,13 @@ def _generate_qeapp_workchain( ) ) if run_bands: - from aiida_quantumespresso.workflows.pw.bands import PwBandsWorkChain + from aiidalab_qe.plugins.bands.bands_workchain import BandsWorkChain bands = generate_bands_workchain(structure) workchain.out_many( workchain.exposed_outputs( bands.node, - PwBandsWorkChain, + BandsWorkChain, namespace="bands", ) ) diff --git a/tests/test_plugins_bands.py b/tests/test_plugins_bands.py index 55692756b..05b4b4e19 100644 --- a/tests/test_plugins_bands.py +++ b/tests/test_plugins_bands.py @@ -39,15 +39,22 @@ def test_result(generate_qeapp_workchain): def test_structure_1d(generate_qeapp_workchain, generate_structure_data): structure = generate_structure_data("silicon", pbc=(True, False, False)) wkchain = generate_qeapp_workchain(structure=structure) - assert "bands_kpoints_distance" not in wkchain.inputs.bands - assert "bands_kpoints" in wkchain.inputs.bands - assert len(wkchain.inputs.bands.bands_kpoints.labels) == 2 + assert "bands_kpoints_distance" not in wkchain.inputs.bands.bands + assert "bands_kpoints" in wkchain.inputs.bands.bands + assert len(wkchain.inputs.bands.bands.bands_kpoints.labels) == 2 + assert wkchain.inputs.bands.bands.bands_kpoints.labels == [(0, "Γ"), (9, "X")] @pytest.mark.usefixtures("aiida_profile_clean", "sssp") def test_structure_2d(generate_qeapp_workchain, generate_structure_data): - structure = generate_structure_data("silicon", pbc=(True, True, False)) + structure = generate_structure_data("MoS2", pbc=(True, True, False)) wkchain = generate_qeapp_workchain(structure=structure) - assert "bands_kpoints_distance" not in wkchain.inputs.bands - assert "bands_kpoints" in wkchain.inputs.bands - assert len(wkchain.inputs.bands.bands_kpoints.labels) == 4 + assert "bands_kpoints_distance" not in wkchain.inputs.bands.bands + assert "bands_kpoints" in wkchain.inputs.bands.bands + assert len(wkchain.inputs.bands.bands.bands_kpoints.labels) == 4 + assert wkchain.inputs.bands.bands.bands_kpoints.labels == [ + (0, "Γ"), + (11, "M"), + (18, "K"), + (31, "Γ"), + ] diff --git a/tests/test_submit_qe_workchain.py b/tests/test_submit_qe_workchain.py index 8763c6c76..1a5955691 100644 --- a/tests/test_submit_qe_workchain.py +++ b/tests/test_submit_qe_workchain.py @@ -71,8 +71,11 @@ def test_create_builder_insulator( # check and validate the builder got = builder_to_readable_dict(builder) - assert got["bands"]["scf"]["pw"]["parameters"]["SYSTEM"]["occupations"] == "fixed" - assert "smearing" not in got["bands"]["scf"]["pw"]["parameters"]["SYSTEM"] + assert ( + got["bands"]["bands"]["scf"]["pw"]["parameters"]["SYSTEM"]["occupations"] + == "fixed" + ) + assert "smearing" not in got["bands"]["bands"]["scf"]["pw"]["parameters"]["SYSTEM"] @pytest.mark.usefixtures("aiida_profile_clean", "sssp") @@ -108,7 +111,7 @@ def test_create_builder_advanced_settings( # test tot_charge is updated in the three steps for parameters in [ got["relax"]["base"], - got["bands"]["scf"], + got["bands"]["bands"]["scf"], got["pdos"]["scf"], got["pdos"]["nscf"], ]: diff --git a/tests/test_submit_qe_workchain/test_create_builder_default.yml b/tests/test_submit_qe_workchain/test_create_builder_default.yml index 63597b234..3ad9d35b7 100644 --- a/tests/test_submit_qe_workchain/test_create_builder_default.yml +++ b/tests/test_submit_qe_workchain/test_create_builder_default.yml @@ -20,7 +20,7 @@ advanced: vdw_corr: none pseudos: {} bands: - kpath_2d: hexagonal + projwfc_bands: false codes: dos: cpus: 1 @@ -34,6 +34,12 @@ codes: max_wallclock_seconds: 43200 nodes: 1 ntasks_per_node: 1 + projwfc_bands: + cpus: 1 + cpus_per_task: 1 + max_wallclock_seconds: 43200 + nodes: 1 + ntasks_per_node: 1 pw: cpus: 2 cpus_per_task: 1 @@ -48,9 +54,9 @@ pdos: workchain: electronic_type: metal properties: - - bands - - pdos - - relax + - bands + - pdos + - relax protocol: moderate relax_type: positions_cell spin_type: none