diff --git a/src/nomad_simulations/schema_packages/workflow/__init__.py b/src/nomad_simulations/schema_packages/workflow/__init__.py new file mode 100644 index 00000000..85f8313d --- /dev/null +++ b/src/nomad_simulations/schema_packages/workflow/__init__.py @@ -0,0 +1,22 @@ +# +# Copyright The NOMAD Authors. +# +# This file is part of NOMAD. +# See https://nomad-lab.eu for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from .base_workflows import BeyondDFT, BeyondDFTMethod, SimulationWorkflow +from .dft_plus_tb import DFTPlusTB, DFTPlusTBMethod +from .single_point import SinglePoint diff --git a/src/nomad_simulations/schema_packages/workflow/base_workflows.py b/src/nomad_simulations/schema_packages/workflow/base_workflows.py new file mode 100644 index 00000000..4e76fb7b --- /dev/null +++ b/src/nomad_simulations/schema_packages/workflow/base_workflows.py @@ -0,0 +1,191 @@ +# +# Copyright The NOMAD Authors. +# +# This file is part of NOMAD. +# See https://nomad-lab.eu for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from functools import wraps +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from nomad.datamodel.datamodel import EntryArchive + from structlog.stdlib import BoundLogger + +from nomad.datamodel.data import ArchiveSection +from nomad.datamodel.metainfo.workflow import Link, Task, Workflow +from nomad.metainfo import SubSection + +from nomad_simulations.schema_packages.model_method import BaseModelMethod +from nomad_simulations.schema_packages.model_system import ModelSystem +from nomad_simulations.schema_packages.outputs import Outputs + + +def check_n_tasks(n_tasks: Optional[int] = None): + """ + Check if the `tasks` of a workflow exist. If the `n_tasks` input specified, it checks whether `tasks` + is of the same length as `n_tasks`. + + Args: + n_tasks (Optional[int], optional): The length of the `tasks` needs to be checked if set to an integer. Defaults to None. + """ + + def decorator(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + if not self.tasks: + return None + if n_tasks is not None and len(self.tasks) != n_tasks: + return None + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +class SimulationWorkflow(Workflow): + """ + A base section used to define the workflows of a simulation with references to specific `tasks`, `inputs`, and `outputs`. The + normalize function checks the definition of these sections and sets the name of the workflow. + + A `SimulationWorkflow` will be composed of: + - a `method` section containing methodological parameters used specifically during the workflow, + - a list of `inputs` with references to the `ModelSystem` or `ModelMethod` input sections, + - a list of `outputs` with references to the `Outputs` section, + - a list of `tasks` containing references to the activity `Simulation` used in the workflow, + """ + + method = SubSection( + sub_section=BaseModelMethod.m_def, + description="""Methodological parameters used during the workflow.""", + ) + + def _resolve_inputs_outputs_from_archive( + self, archive: 'EntryArchive', logger: 'BoundLogger' + ) -> None: + """ + Resolves the `ModelSystem`, `ModelMethod`, and `Outputs` sections from the archive and stores + them in private attributes. + + Args: + archive (EntryArchive): The archive to resolve the sections from. + logger (BoundLogger): The logger to log messages. + """ + self._input_systems = [] + self._input_methods = [] + self._outputs = [] + if ( + not archive.data.model_system + or not archive.data.model_method + or not archive.data.outputs + ): + logger.info( + '`ModelSystem`, `ModelMethod` and `Outputs` required for normalization of `SimulationWorkflow`.' + ) + return None + self._input_systems = archive.data.model_system + self._input_methods = archive.data.model_method + self._outputs = archive.data.outputs + + def resolve_inputs_outputs( + self, archive: 'EntryArchive', logger: 'BoundLogger' + ) -> None: + """ + Resolves the `inputs` and `outputs` of the `SimulationWorkflow`. + + Args: + archive (EntryArchive): The archive to resolve the sections from. + logger (BoundLogger): The logger to log messages. + """ + self._resolve_inputs_outputs_from_archive(archive=archive, logger=logger) + + # Resolve `inputs` + if not self.inputs and self._input_systems: + self.m_add_sub_section( + Workflow.inputs, + Link(name='Input Model System', section=self._input_systems[0]), + ) + # Resolve `outputs` + if not self.outputs and self._outputs: + self.m_add_sub_section( + Workflow.outputs, + Link(name='Output Data', section=self._outputs[-1]), + ) + + def normalize(self, archive: 'EntryArchive', logger: 'BoundLogger') -> None: + super().normalize(archive, logger) + + # Resolve the `inputs` and `outputs` from the archive + self.resolve_inputs_outputs(archive=archive, logger=logger) + + # Storing the initial `ModelSystem` + for link in self.inputs: + if isinstance(link.section, ModelSystem): + self.initial_structure = link.section + break + + +class BeyondDFTMethod(ArchiveSection): + """ + An abstract section used to store references to the `ModelMethod` sections of each of the + archives defining the `tasks` and used to build the standard workflow. This section needs to be + inherit and the method references need to be defined for each specific case. + """ + + def resolve_beyonddft_method_ref( + self, task: Optional[Task] + ) -> Optional[BaseModelMethod]: + """ + Resolves the `ModelMethod` reference for the `task`. + + Args: + task (Task): The task to resolve the `ModelMethod` reference from. + + Returns: + Optional[BaseModelMethod]: The resolved `ModelMethod` reference. + """ + if not task or not task.inputs: + return None + for input in task.inputs: + if input.section is not None and isinstance(input.section, BaseModelMethod): + return input.section + return None + + +class BeyondDFT(SimulationWorkflow): + method = SubSection(sub_section=BeyondDFTMethod.m_def) + + @check_n_tasks() + def resolve_all_outputs(self) -> list[Outputs]: + """ + Resolves all the `Outputs` sections from the `tasks` in the workflow. This is useful when + the workflow is composed of multiple tasks and the outputs need to be stored in a list + for further manipulation, e.g., to plot multiple band structures in a DFT+TB workflow. + + Returns: + list[Outputs]: A list of all the `Outputs` sections from the `tasks`. + """ + # Populate the list of outputs from the last element in `tasks` + all_outputs = [] + for task in self.tasks: + if not task.outputs: + continue + all_outputs.append(task.outputs[-1]) + return all_outputs + + def normalize(self, archive: 'EntryArchive', logger: 'BoundLogger') -> None: + super().normalize(archive, logger) diff --git a/src/nomad_simulations/schema_packages/workflow/dft_plus_tb.py b/src/nomad_simulations/schema_packages/workflow/dft_plus_tb.py new file mode 100644 index 00000000..b6ccb36e --- /dev/null +++ b/src/nomad_simulations/schema_packages/workflow/dft_plus_tb.py @@ -0,0 +1,200 @@ +# +# Copyright The NOMAD Authors. +# +# This file is part of NOMAD. +# See https://nomad-lab.eu for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from nomad.datamodel.datamodel import EntryArchive + from structlog.stdlib import BoundLogger + +from nomad.datamodel.metainfo.workflow import Link +from nomad.metainfo import Quantity, Reference + +from nomad_simulations.schema_packages.model_method import BaseModelMethod +from nomad_simulations.schema_packages.properties import FermiLevel +from nomad_simulations.schema_packages.workflow import ( + BeyondDFT, + BeyondDFTMethod, +) +from nomad_simulations.schema_packages.workflow.base_workflows import check_n_tasks + + +class DFTPlusTBMethod(BeyondDFTMethod): + """ + Section used to reference the `DFT` and `TB` `ModelMethod` sections in each of the archives + conforming a DFT+TB simulation workflow. + """ + + dft_method_ref = Quantity( + type=Reference(BaseModelMethod), + description="""Reference to the DFT `ModelMethod` section in the DFT task.""", + ) + tb_method_ref = Quantity( + type=Reference(BaseModelMethod), + description="""Reference to the GW `ModelMethod` section in the TB task.""", + ) + + +class DFTPlusTB(BeyondDFT): + """ + DFT+TB workflow is composed of two tasks: the initial DFT calculation + the final TB projection. This + workflow section is used to define the same energy reference for both the DFT and TB calculations, by + setting it up to the DFT calculation. The structure of the workflow is: + + - `self.inputs[0]`: the initial `ModelSystem` section in the DFT entry, + - `self.outputs[0]`: the outputs section in the TB entry, + - `tasks[0]`: + - `tasks[0].task` (TaskReference): the reference to the `SinglePoint` task in the DFT entry, + - `tasks[0].inputs[0]`: the initial `ModelSystem` section in the DFT entry, + - `tasks[0].outputs[0]`: the outputs section in the DFT entry, + - `tasks[1]`: + - `tasks[1].task` (TaskReference): the reference to the `SinglePoint` task in the TB entry, + - `tasks[1].inputs[0]`: the outputs section in the DFT entry, + - `tasks[1].outputs[0]`: the outputs section in the TB entry, + - `method`: references to the `ModelMethod` sections in the DFT and TB entries. + """ + + @check_n_tasks(n_tasks=2) + def resolve_method(self) -> DFTPlusTBMethod: + """ + Resolves the `DFT` and `TB` `ModelMethod` references for the `tasks` in the workflow by using the + `resolve_beyonddft_method_ref` method from the `BeyondDFTMethod` section. + + Returns: + DFTPlusTBMethod: The resolved `DFTPlusTBMethod` section. + """ + method = DFTPlusTBMethod() + + # Check if TaskReference exists for both tasks + for task in self.tasks: + if not task.task: + return None + + # DFT method reference + dft_method = method.resolve_beyonddft_method_ref(task=self.tasks[0].task) + if dft_method is not None: + method.dft_method_ref = dft_method + + # TB method reference + tb_method = method.resolve_beyonddft_method_ref(task=self.tasks[1].task) + if tb_method is not None: + method.tb_method_ref = tb_method + + return method + + @check_n_tasks(n_tasks=2) + def link_tasks(self) -> None: + """ + Links the `outputs` of the DFT task with the `inputs` of the TB task. + """ + # Initial checks on the `inputs` and `tasks[*].outputs` + if not self.inputs: + return None + for task in self.tasks: + if not task.m_xpath('task.outputs'): + return None + + # Assign dft task `inputs` to the `self.inputs[0]` + dft_task = self.tasks[0] + dft_task.inputs = [ + Link( + name='Input Model System', + section=self.inputs[0], + ) + ] + # and rewrite dft task `outputs` and its name + dft_task.outputs = [ + Link( + name='Output DFT Data', + section=dft_task.task.outputs[-1], + ) + ] + + # Assign tb task `inputs` to the `dft_task.outputs[-1]` + tb_task = self.tasks[1] + tb_task.inputs = [ + Link( + name='Output DFT Data', + section=dft_task.task.outputs[-1], + ), + ] + # and rewrite tb task `outputs` and its name + tb_task.outputs = [ + Link( + name='Output TB Data', + section=tb_task.task.outputs[-1], + ) + ] + + @check_n_tasks(n_tasks=2) + def overwrite_fermi_level(self) -> None: + """ + Overwrites the Fermi level in the TB calculation with the Fermi level from the DFT calculation. + """ + # Check if the `outputs` of the DFT task exist + dft_task = self.tasks[0] + if not dft_task.outputs: + self.link_tasks() + + # Check if the `fermi_levels` exist in the DFT output + if not dft_task.m_xpath('outputs[-1].section'): + return None + dft_output = dft_task.outputs[-1].section + if not dft_output.fermi_levels: + return None + fermi_level = dft_output.fermi_levels[-1] + + # Assign the Fermi level to the TB output + tb_task = self.tasks[1] + if not tb_task.m_xpath('outputs[-1].section'): + return None + tb_output = tb_task.outputs[-1].section + # ? Does appending like this work creating information in the TB entry? + tb_output.fermi_levels.append(FermiLevel(value=fermi_level.value)) + + def normalize(self, archive: 'EntryArchive', logger: 'BoundLogger') -> None: + super().normalize(archive, logger) + + # Initial check for the number of tasks + if not self.tasks or len(self.tasks) != 2: + logger.error('A `DFTPlusTB` workflow must have two tasks.') + return + + # Check if tasks are `SinglePoint` + for task in self.tasks: + if task.m_def.name != 'SinglePoint': + logger.error( + 'A `DFTPlusTB` workflow must have two `SinglePoint` tasks.' + ) + return + + # Define names of the workflow and `tasks` + self.name = 'DFT+TB' + self.tasks[0].name = 'DFT SinglePoint' + self.tasks[1].name = 'TB SinglePoint' + + # Resolve method refs for each task and store under `method` + self.method = self.resolve_method() + + # Link the tasks + self.link_tasks() + + # Overwrite the Fermi level in the TB calculation + # ? test if overwritting works + self.overwrite_fermi_level() diff --git a/src/nomad_simulations/schema_packages/workflow/single_point.py b/src/nomad_simulations/schema_packages/workflow/single_point.py new file mode 100644 index 00000000..2a24b8f4 --- /dev/null +++ b/src/nomad_simulations/schema_packages/workflow/single_point.py @@ -0,0 +1,118 @@ +# +# Copyright The NOMAD Authors. +# +# This file is part of NOMAD. +# See https://nomad-lab.eu for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from nomad.datamodel.datamodel import EntryArchive + from structlog.stdlib import BoundLogger + +from nomad.datamodel.metainfo.workflow import Link, Task +from nomad.metainfo import Quantity + +from nomad_simulations.schema_packages.outputs import SCFOutputs +from nomad_simulations.schema_packages.workflow import SimulationWorkflow + + +class SinglePoint(SimulationWorkflow): + """ + A `SimulationWorkflow` used to represent a single point calculation workflow. The `SinglePoint` + workflow is the minimum workflow required to represent a simulation. The self-consistent steps of + scf simulation are represented in the `SinglePoint` workflow. + """ + + n_scf_steps = Quantity( + type=np.int32, + description=""" + The number of self-consistent field (SCF) steps in the simulation. + """, + ) + + def generate_task(self, archive: 'EntryArchive', logger: 'BoundLogger') -> Task: + """ + Generates the `Task` section for the `SinglePoint` workflow with their `inputs` and `outputs`. + + Returns: + Task: The generated `Task` section. + """ + # Populate `_input_systems`, `_input_methods` and `_outputs` + self._resolve_inputs_outputs_from_archive(archive=archive, logger=logger) + + # Generate the `Task` section + task = Task() + if self._input_systems: + task.m_add_sub_section( + Task.inputs, + Link(name='Input Model System', section=self._input_systems[0]), + ) + if self._input_methods: + task.m_add_sub_section( + Task.inputs, + Link(name='Input Model Method', section=self._input_methods[0]), + ) + if self._outputs: + task.m_add_sub_section( + Task.outputs, + Link(name='Output Data', section=self._outputs[-1]), + ) + return task + + def resolve_n_scf_steps(self) -> int: + """ + Resolves the number of self-consistent field (SCF) steps in the simulation. + + Returns: + int: The number of SCF steps. + """ + # Initial check + if not self.outputs: + return 1 + for output in self.outputs: + # Check if `self.outputs` has a `section` + if not output.section: + continue + # Check if the section is `SCFOutputs` + if not isinstance(output.section, SCFOutputs): + continue + scf_output = output.section + # Check if there are `scf_steps` + if not scf_output.scf_steps: + continue + return len(scf_output.scf_steps) + return 1 + + def normalize(self, archive: 'EntryArchive', logger: 'BoundLogger') -> None: + super().normalize(archive, logger) + + # SinglePoint can only have one task; if it has more, delete the `tasks` + if self.tasks is not None and len(self.tasks) > 1: + logger.error('A `SinglePoint` workflow must have only one task.') + self.tasks: list[Task] = [] + return + + # Generate the `tasks` section if this does not exist + if not self.tasks: + task = self.generate_task(archive=archive, logger=logger) + self.tasks.append(task) + + # Resolve `n_scf_steps` + self.n_scf_steps = self.resolve_n_scf_steps() diff --git a/tests/properties/__init__.py b/tests/properties/__init__.py new file mode 100644 index 00000000..52e83b1e --- /dev/null +++ b/tests/properties/__init__.py @@ -0,0 +1,21 @@ +# +# Copyright The NOMAD Authors. +# +# This file is part of NOMAD. See https://nomad-lab.eu for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from nomad import utils + +logger = utils.get_logger(__name__) diff --git a/tests/test_band_gap.py b/tests/properties/test_band_gap.py similarity index 100% rename from tests/test_band_gap.py rename to tests/properties/test_band_gap.py diff --git a/tests/test_band_structure.py b/tests/properties/test_band_structure.py similarity index 99% rename from tests/test_band_structure.py rename to tests/properties/test_band_structure.py index 1e81aab5..9f931c49 100644 --- a/tests/test_band_structure.py +++ b/tests/properties/test_band_structure.py @@ -24,8 +24,8 @@ from nomad_simulations.schema_packages.properties import ElectronicEigenvalues +from ..conftest import generate_electronic_eigenvalues from . import logger -from .conftest import generate_electronic_eigenvalues, generate_simulation class TestElectronicEigenvalues: diff --git a/tests/test_energies.py b/tests/properties/test_energies.py similarity index 100% rename from tests/test_energies.py rename to tests/properties/test_energies.py diff --git a/tests/test_fermi_surface.py b/tests/properties/test_fermi_surface.py similarity index 100% rename from tests/test_fermi_surface.py rename to tests/properties/test_fermi_surface.py diff --git a/tests/test_forces.py b/tests/properties/test_forces.py similarity index 100% rename from tests/test_forces.py rename to tests/properties/test_forces.py diff --git a/tests/test_greens_function.py b/tests/properties/test_greens_function.py similarity index 100% rename from tests/test_greens_function.py rename to tests/properties/test_greens_function.py diff --git a/tests/test_hopping_matrix.py b/tests/properties/test_hopping_matrix.py similarity index 100% rename from tests/test_hopping_matrix.py rename to tests/properties/test_hopping_matrix.py diff --git a/tests/test_permittivity.py b/tests/properties/test_permittivity.py similarity index 99% rename from tests/test_permittivity.py rename to tests/properties/test_permittivity.py index f6955f97..0a6b41ce 100644 --- a/tests/test_permittivity.py +++ b/tests/properties/test_permittivity.py @@ -24,8 +24,8 @@ from nomad_simulations.schema_packages.properties import Permittivity from nomad_simulations.schema_packages.variables import Frequency, KMesh, Variables +from ..conftest import generate_k_space_simulation from . import logger -from .conftest import generate_k_space_simulation class TestPermittivity: diff --git a/tests/test_spectral_profile.py b/tests/properties/test_spectral_profile.py similarity index 100% rename from tests/test_spectral_profile.py rename to tests/properties/test_spectral_profile.py diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 00000000..52e83b1e --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1,21 @@ +# +# Copyright The NOMAD Authors. +# +# This file is part of NOMAD. See https://nomad-lab.eu for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from nomad import utils + +logger = utils.get_logger(__name__) diff --git a/tests/test_utils.py b/tests/utils/test_utils.py similarity index 100% rename from tests/test_utils.py rename to tests/utils/test_utils.py diff --git a/tests/workflow/__init__.py b/tests/workflow/__init__.py new file mode 100644 index 00000000..52e83b1e --- /dev/null +++ b/tests/workflow/__init__.py @@ -0,0 +1,21 @@ +# +# Copyright The NOMAD Authors. +# +# This file is part of NOMAD. See https://nomad-lab.eu for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from nomad import utils + +logger = utils.get_logger(__name__) diff --git a/tests/workflow/test_base_workflows.py b/tests/workflow/test_base_workflows.py new file mode 100644 index 00000000..da6797fb --- /dev/null +++ b/tests/workflow/test_base_workflows.py @@ -0,0 +1,339 @@ +# +# Copyright The NOMAD Authors. +# +# This file is part of NOMAD. See https://nomad-lab.eu for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from typing import Optional + +import pytest +from nomad.datamodel import EntryArchive +from nomad.datamodel.metainfo.workflow import Link, Task + +from nomad_simulations.schema_packages.model_method import BaseModelMethod, ModelMethod +from nomad_simulations.schema_packages.model_system import ModelSystem +from nomad_simulations.schema_packages.outputs import Outputs +from nomad_simulations.schema_packages.workflow import ( + BeyondDFT, + BeyondDFTMethod, + SimulationWorkflow, +) + +from ..conftest import generate_simulation +from . import logger + + +class TestSimulationWorkflow: + @pytest.mark.parametrize( + 'model_system, model_method, outputs', + [ + # empty sections in archive.data + (None, None, None), + # only one section in archive.data + (ModelSystem(), None, None), + # another section in archive.data + (None, ModelMethod(), None), + # only two sections in archive.data + (ModelSystem(), ModelMethod(), None), + # all sections in archive.data + (ModelSystem(), ModelMethod(), Outputs()), + ], + ) + def test_resolve_inputs_outputs_from_archive( + self, + model_system: Optional[ModelSystem], + model_method: Optional[ModelMethod], + outputs: Optional[Outputs], + ): + """ + Test the `_resolve_inputs_outputs_from_archive` method of the `SimulationWorkflow` section. + """ + archive = EntryArchive() + simulation = generate_simulation( + model_system=model_system, model_method=model_method, outputs=outputs + ) + archive.data = simulation + workflow = SimulationWorkflow() + archive.workflow2 = workflow + workflow._resolve_inputs_outputs_from_archive(archive=archive, logger=logger) + if ( + model_system is not None + and model_method is not None + and outputs is not None + ): + for input_system in workflow._input_systems: + assert isinstance(input_system, ModelSystem) + for input_method in workflow._input_methods: + assert isinstance(input_method, ModelMethod) + for output in workflow._outputs: + assert isinstance(output, Outputs) + else: + assert not workflow._input_systems + assert not workflow._input_methods + assert not workflow._outputs + + @pytest.mark.parametrize( + 'model_system, model_method, outputs, workflow_inputs, workflow_outputs', + [ + # empty sections in archive.data + (None, None, None, [], []), + # only one section in archive.data + (ModelSystem(), None, None, [], []), + # another section in archive.data + (None, ModelMethod(), None, [], []), + # only two sections in archive.data + (ModelSystem(), ModelMethod(), None, [], []), + # all sections in archive.data + ( + ModelSystem(), + ModelMethod(), + Outputs(), + [Link(name='Input Model System', section=ModelSystem())], + [Link(name='Output Data', section=Outputs())], + ), + ], + ) + def test_resolve_inputs_outputs( + self, + model_system: Optional[ModelSystem], + model_method: Optional[ModelMethod], + outputs: Optional[Outputs], + workflow_inputs: list[Link], + workflow_outputs: list[Link], + ): + """ + Test the `resolve_inputs_outputs` method of the `SimulationWorkflow` section. + """ + archive = EntryArchive() + simulation = generate_simulation( + model_system=model_system, model_method=model_method, outputs=outputs + ) + archive.data = simulation + workflow = SimulationWorkflow() + archive.workflow2 = workflow + + workflow.resolve_inputs_outputs(archive=archive, logger=logger) + if not workflow_inputs: + assert workflow.inputs == workflow_inputs + else: + assert len(workflow.inputs) == 1 + assert workflow.inputs[0].name == workflow_inputs[0].name + # ! direct comparison of section does not work (probably an issue with references) + # assert workflow.inputs[0].section == workflow_inputs[0].section + if not workflow_outputs: + assert workflow.outputs == workflow_outputs + else: + assert len(workflow.outputs) == 1 + assert workflow.outputs[0].name == workflow_outputs[0].name + # ! direct comparison of section does not work (probably an issue with references) + # assert workflow.outputs[0].section == workflow_outputs[0].section + + @pytest.mark.parametrize( + 'model_system, model_method, outputs, workflow_inputs, workflow_outputs', + [ + # empty sections in archive.data + (None, None, None, [], []), + # only one section in archive.data + (ModelSystem(), None, None, [], []), + # another section in archive.data + (None, ModelMethod(), None, [], []), + # only two sections in archive.data + (ModelSystem(), ModelMethod(), None, [], []), + # all sections in archive.data + ( + ModelSystem(), + ModelMethod(), + Outputs(), + [Link(name='Input Model System', section=ModelSystem())], + [Link(name='Output Data', section=Outputs())], + ), + ], + ) + def test_normalize( + self, + model_system: Optional[ModelSystem], + model_method: Optional[ModelMethod], + outputs: Optional[Outputs], + workflow_inputs: list[Link], + workflow_outputs: list[Link], + ): + """ + Test the `normalize` method of the `SimulationWorkflow` section. + """ + archive = EntryArchive() + simulation = generate_simulation( + model_system=model_system, model_method=model_method, outputs=outputs + ) + archive.data = simulation + workflow = SimulationWorkflow() + archive.workflow2 = workflow + + workflow.normalize(archive=archive, logger=logger) + if not workflow_inputs: + assert workflow.inputs == workflow_inputs + else: + assert len(workflow.inputs) == 1 + assert workflow.inputs[0].name == workflow_inputs[0].name + # ! direct comparison of section does not work (probably an issue with references) + # assert workflow.inputs[0].section == workflow_inputs[0].section + assert workflow._input_systems[0] == model_system + assert workflow._input_methods[0] == model_method + # Extra attribute from the `normalize` function + # ! direct comparison of section does not work (probably an issue with references) + # assert workflow.initial_structure == workflow_inputs[0].section + if not workflow_outputs: + assert workflow.outputs == workflow_outputs + else: + assert len(workflow.outputs) == 1 + assert workflow.outputs[0].name == workflow_outputs[0].name + # ! direct comparison of section does not work (probably an issue with references) + # assert workflow.outputs[0].section == workflow_outputs[0].section + assert workflow._outputs[0] == outputs + + +class TestBeyondDFTMethod: + @pytest.mark.parametrize( + 'task, result', + [ + # no task + (None, None), + # empty task + (Task(), None), + # task only contains ModelSystem + ( + Task(inputs=[Link(name='Input Model System', section=ModelSystem())]), + None, + ), + # no `section` in the link + ( + Task(inputs=[Link(name='Input Model Method')]), + None, + ), + # task only contains ModelMethod + ( + Task(inputs=[Link(name='Input Model Method', section=ModelMethod())]), + ModelMethod(), + ), + # task contains both ModelSystem and ModelMethod + ( + Task( + inputs=[ + Link(name='Input Model System', section=ModelSystem()), + Link(name='Input Model Method', section=ModelMethod()), + ] + ), + ModelMethod(), + ), + ], + ) + def test_resolve_beyonddft_method_ref( + self, task: Optional[Task], result: Optional[BaseModelMethod] + ): + """ + Test the `resolve_beyonddft_method_ref` method of the `BeyondDFTMethod` section. + """ + beyond_dft_method = BeyondDFTMethod() + # ! direct comparison of section does not work (probably an issue with references) + if result is not None: + assert ( + beyond_dft_method.resolve_beyonddft_method_ref(task=task).m_def.name + == result.m_def.name + ) + else: + assert beyond_dft_method.resolve_beyonddft_method_ref(task=task) == result + + +class TestBeyondDFT: + @pytest.mark.parametrize( + 'tasks, result', + [ + # no task + (None, None), + # empty task + ([Task()], []), + # task only contains inputs + ( + [Task(inputs=[Link(name='Input Model System', section=ModelSystem())])], + [], + ), + # one task with one output + ( + [Task(outputs=[Link(name='Output Data 1', section=Outputs())])], + [Link(name='Output Data 1', section=Outputs())], + ), + # one task with multiple outputs (only last is resolved) + ( + [ + Task( + outputs=[ + Link(name='Output Data 1', section=Outputs()), + Link(name='Output Data 2', section=Outputs()), + ] + ) + ], + [Link(name='Output Data 2', section=Outputs())], + ), + # multiple task with one output each + ( + [ + Task( + outputs=[Link(name='Task 1:Output Data 1', section=Outputs())] + ), + Task( + outputs=[Link(name='Task 2:Output Data 1', section=Outputs())] + ), + ], + [ + Link(name='Task 1:Output Data 1', section=Outputs()), + Link(name='Task 2:Output Data 1', section=Outputs()), + ], + ), + # multiple task with two outputs each (only last is resolved) + ( + [ + Task( + outputs=[ + Link(name='Task 1:Output Data 1', section=Outputs()), + Link(name='Task 1:Output Data 2', section=Outputs()), + ] + ), + Task( + outputs=[ + Link(name='Task 2:Output Data 1', section=Outputs()), + Link(name='Task 2:Output Data 2', section=Outputs()), + ] + ), + ], + [ + Link(name='Task 1:Output Data 2', section=Outputs()), + Link(name='Task 2:Output Data 2', section=Outputs()), + ], + ), + ], + ) + def test_resolve_all_outputs( + self, tasks: Optional[list[Task]], result: list[Outputs] + ): + """ + Test the `resolve_all_outputs` method of the `BeyondDFT` section. + """ + workflow = BeyondDFT() + if tasks is not None: + workflow.tasks = tasks + if result is not None: + for i, output in enumerate(workflow.resolve_all_outputs()): + assert output.name == result[i].name + else: + assert workflow.resolve_all_outputs() == result diff --git a/tests/workflow/test_dft_plus_tb.py b/tests/workflow/test_dft_plus_tb.py new file mode 100644 index 00000000..c9e68e68 --- /dev/null +++ b/tests/workflow/test_dft_plus_tb.py @@ -0,0 +1,199 @@ +# +# Copyright The NOMAD Authors. +# +# This file is part of NOMAD. See https://nomad-lab.eu for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from typing import Optional + +import pytest +from nomad.datamodel import EntryArchive +from nomad.datamodel.metainfo.workflow import Link, Task, TaskReference, Workflow + +from nomad_simulations.schema_packages.model_method import ( + DFT, + TB, + BaseModelMethod, + ModelMethod, +) +from nomad_simulations.schema_packages.model_system import ModelSystem +from nomad_simulations.schema_packages.outputs import Outputs +from nomad_simulations.schema_packages.workflow import ( + DFTPlusTB, + DFTPlusTBMethod, +) + +from ..conftest import generate_simulation +from . import logger + + +class TestDFTPlusTB: + @pytest.mark.parametrize( + 'tasks, result', + [ + (None, None), + ([TaskReference(name='dft')], None), + ( + [ + TaskReference(name='dft'), + TaskReference(name='tb 1'), + TaskReference(name='tb 2'), + ], + None, + ), + ([TaskReference(name='dft'), TaskReference(name='tb')], None), + ( + [ + TaskReference(name='dft', task=Task(name='dft task')), + TaskReference(name='tb'), + ], + None, + ), + ( + [ + TaskReference( + name='dft', + task=Task( + name='dft task', + inputs=[ + Link(name='model system', section=ModelSystem()), + Link(name='model method dft', section=DFT()), + ], + ), + ), + TaskReference( + name='tb', + task=Task(name='tb task'), + ), + ], + [DFT, None], + ), + ( + [ + TaskReference( + name='dft', + task=Task( + name='dft task', + inputs=[ + Link(name='model system', section=ModelSystem()), + Link(name='model method dft', section=DFT()), + ], + ), + ), + TaskReference( + name='tb', + task=Task( + name='tb task', + inputs=[ + Link(name='model system', section=ModelSystem()), + Link(name='model method tb', section=TB()), + ], + ), + ), + ], + [DFT, TB], + ), + ], + ) + def test_resolve_method( + self, + tasks: list[Task], + result: DFTPlusTBMethod, + ): + """ + Test the `resolve_method` method of the `DFTPlusTB` section. + """ + archive = EntryArchive() + workflow = DFTPlusTB() + archive.workflow2 = workflow + workflow.tasks = tasks + workflow_method = workflow.resolve_method() + if workflow_method is None: + assert workflow_method == result + else: + if result[0] is not None: + assert isinstance(workflow_method.dft_method_ref, result[0]) + else: + assert workflow_method.dft_method_ref == result[0] + if result[1] is not None: + assert isinstance(workflow_method.tb_method_ref, result[1]) + else: + assert workflow_method.tb_method_ref == result[1] + + def test_link_tasks(self): + """ + Test the `resolve_n_scf_steps` method of the `DFTPlusTB` section. + """ + archive = EntryArchive() + workflow = DFTPlusTB() + archive.workflow2 = workflow + workflow.tasks = [ + TaskReference( + name='dft', + task=Task( + name='dft task', + inputs=[ + Link(name='model system', section=ModelSystem()), + Link(name='model method dft', section=DFT()), + ], + outputs=[ + Link(name='output dft', section=Outputs()), + ], + ), + ), + TaskReference( + name='tb', + task=Task( + name='tb task', + inputs=[ + Link(name='model system', section=ModelSystem()), + Link(name='model method tb', section=TB()), + ], + outputs=[ + Link(name='output tb', section=Outputs()), + ], + ), + ), + ] + workflow.inputs = [Link(name='model system', section=ModelSystem())] + workflow.outputs = [Link(name='output tb', section=Outputs())] + + # Linking and overwritting inputs and outputs + workflow.link_tasks() + + dft_task = workflow.tasks[0] + assert len(dft_task.inputs) == 1 + assert dft_task.inputs[0].name == 'Input Model System' + assert len(dft_task.outputs) == 1 + assert dft_task.outputs[0].name == 'Output DFT Data' + tb_task = workflow.tasks[1] + assert len(tb_task.inputs) == 1 + assert tb_task.inputs[0].name == 'Output DFT Data' + assert len(tb_task.outputs) == 1 + assert tb_task.outputs[0].name == 'Output TB Data' + + def test_overwrite_fermi_level(self): + """ + Test the `overwrite_fermi_level` method of the `DFTPlusTB` section. + """ + # TODO implement once testing in a real case is tested (Wannier90 parser) + assert True + + def test_normalize(self): + """ + Test the `normalize` method of the `DFTPlusTB` section. + """ + # TODO implement once testing in a real case is tested (Wannier90 parser) + assert True diff --git a/tests/workflow/test_single_point.py b/tests/workflow/test_single_point.py new file mode 100644 index 00000000..d43b8da2 --- /dev/null +++ b/tests/workflow/test_single_point.py @@ -0,0 +1,265 @@ +# +# Copyright The NOMAD Authors. +# +# This file is part of NOMAD. See https://nomad-lab.eu for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from typing import Optional + +import pytest +from nomad.datamodel import EntryArchive +from nomad.datamodel.metainfo.workflow import Link, Task + +from nomad_simulations.schema_packages.model_method import ModelMethod +from nomad_simulations.schema_packages.model_system import ModelSystem +from nomad_simulations.schema_packages.outputs import Outputs, SCFOutputs +from nomad_simulations.schema_packages.workflow import SinglePoint + +from ..conftest import generate_simulation +from . import logger + + +class TestSinglePoint: + @pytest.mark.parametrize( + 'model_system, model_method, outputs, result', + [ + # empty sections in archive.data + (None, None, None, Task()), + # only one section in archive.data + (ModelSystem(), None, None, Task()), + # another section in archive.data + (None, ModelMethod(), None, Task()), + # only two sections in archive.data + (ModelSystem(), ModelMethod(), None, Task()), + # all sections in archive.data + ( + ModelSystem(), + ModelMethod(), + Outputs(), + Task( + inputs=[ + Link(name='Input Model System', section=ModelSystem()), + Link(name='Input Model Method', section=ModelMethod()), + ], + outputs=[ + Link(name='Output Data', section=Outputs()), + ], + ), + ), + ], + ) + def test_generate_task( + self, + model_system: Optional[ModelSystem], + model_method: Optional[ModelMethod], + outputs: Optional[Outputs], + result: Task, + ): + """ + Test the `generate_task` method of the `SinglePoint` section. + """ + archive = EntryArchive() + simulation = generate_simulation( + model_system=model_system, model_method=model_method, outputs=outputs + ) + archive.data = simulation + workflow = SinglePoint() + archive.workflow2 = workflow + + single_point_task = workflow.generate_task(archive=archive, logger=logger) + if not result.inputs: + assert isinstance(single_point_task, Task) + assert not single_point_task.inputs and not single_point_task.outputs + else: + assert single_point_task.inputs[0].name == result.inputs[0].name + assert single_point_task.inputs[1].name == result.inputs[1].name + assert single_point_task.outputs[0].name == result.outputs[0].name + + @pytest.mark.parametrize( + 'scf_output, result', + [ + # no outputs + (None, 1), + # output is not of type SCFOutputs + (Outputs(), 1), + # SCFOutputs without scf_steps + (SCFOutputs(), 1), + # 3 scf_steps + (SCFOutputs(scf_steps=[Outputs(), Outputs(), Outputs()]), 3), + ], + ) + def test_resolve_n_scf_steps(self, scf_output: Outputs, result: int): + """ + Test the `resolve_n_scf_steps` method of the `SinglePoint` section. + """ + archive = EntryArchive() + simulation = generate_simulation( + model_system=ModelSystem(), model_method=ModelMethod(), outputs=scf_output + ) + archive.data = simulation + workflow = SinglePoint() + archive.workflow2 = workflow + + # Add the scf output to the workflow.outputs + if scf_output is not None: + workflow.outputs = [ + Link(name='SCF Output Data', section=archive.data.outputs[-1]) + ] + + n_scf_steps = workflow.resolve_n_scf_steps() + assert n_scf_steps == result + + @pytest.mark.parametrize( + 'model_system, model_method, outputs, tasks, result_task, result_n_scf_steps', + [ + # multiple tasks being stored in SinglePoint + ( + ModelSystem(), + ModelMethod(), + Outputs(), + [Task(name='task 1'), Task(name='task 2')], + [], + None, + ), + # only one task is being stored in SinglePoint + ( + ModelSystem(), + ModelMethod(), + Outputs(), + [Task(name='parsed task')], + [Task(name='parsed task')], + 1, + ), + # no archive sections (empty generated task) + (None, None, None, None, [Task(name='generated task')], 1), + # only one section in archive.data + (ModelSystem(), None, None, None, [Task(name='generated task')], 1), + # another section in archive.data + (None, ModelMethod(), None, None, [Task(name='generated task')], 1), + # only two sections in archive.data + ( + ModelSystem(), + ModelMethod(), + None, + None, + [Task(name='generated task')], + 1, + ), + # all sections in archive.data, so generated task has inputs and outputs + ( + ModelSystem(), + ModelMethod(), + Outputs(), + None, + [ + Task( + name='generated task', + inputs=[ + Link(name='Input Model System', section=ModelSystem()), + Link(name='Input Model Method', section=ModelMethod()), + ], + outputs=[ + Link(name='Output Data', section=Outputs()), + ], + ) + ], + 1, + ), + # Outputs is SCFOutputs but no scf_steps + ( + ModelSystem(), + ModelMethod(), + SCFOutputs(), + None, + [ + Task( + name='generated task', + inputs=[ + Link(name='Input Model System', section=ModelSystem()), + Link(name='Input Model Method', section=ModelMethod()), + ], + outputs=[ + Link(name='Output Data', section=SCFOutputs()), + ], + ) + ], + 1, + ), + # 3 scf_steps + ( + ModelSystem(), + ModelMethod(), + SCFOutputs(scf_steps=[Outputs(), Outputs(), Outputs()]), + None, + [ + Task( + name='generated task', + inputs=[ + Link(name='Input Model System', section=ModelSystem()), + Link(name='Input Model Method', section=ModelMethod()), + ], + outputs=[ + Link( + name='Output Data', + section=SCFOutputs( + scf_steps=[Outputs(), Outputs(), Outputs()] + ), + ), + ], + ) + ], + 3, + ), + ], + ) + def test_normalize( + self, + model_system: Optional[ModelSystem], + model_method: Optional[ModelMethod], + outputs: Optional[Outputs], + tasks: list[Task], + result_task: list[Task], + result_n_scf_steps: int, + ): + """ + Test the `normalize` method of the `SinglePoint` section. + """ + archive = EntryArchive() + simulation = generate_simulation( + model_system=model_system, model_method=model_method, outputs=outputs + ) + archive.data = simulation + workflow = SinglePoint() + archive.workflow2 = workflow + + if tasks is not None: + workflow.tasks = tasks + + workflow.normalize(archive=archive, logger=logger) + + if not result_task: + assert workflow.tasks == result_task + else: + single_point_task = workflow.tasks[0] + if not result_task[0].inputs: + assert isinstance(single_point_task, Task) + assert not single_point_task.inputs and not single_point_task.outputs + else: + assert single_point_task.inputs[0].name == result_task[0].inputs[0].name + assert single_point_task.inputs[1].name == result_task[0].inputs[1].name + assert ( + single_point_task.outputs[0].name == result_task[0].outputs[0].name + ) + assert workflow.n_scf_steps == result_n_scf_steps