From f03e514ff8d270619b9f1750cb7ed249ce425200 Mon Sep 17 00:00:00 2001 From: "Jose M. Pizarro" <112697669+JosePizarro3@users.noreply.github.com> Date: Thu, 11 Apr 2024 13:29:39 +0200 Subject: [PATCH] `PhysicalProperty` definition (#43) * Initial try defining Variables and PhysicalProperty Added extraction of full_shape of the PhyiscalProperty.value from variables and tensor rank * Reorganized in 3 modules * Added normalization for Variables.n_grid_points * Moved sfc refs and boolean to PhysicalProperty Added functionality to see if a property is_scf_converged * Added testing for Variables * Added testing for PhysicalProperty * Fixed variables dimensions * Added name to PhysicalProperty for custom properties * Added testing for outputs.py * Defined value as a placeholder_quantity default * Improved descriptions --------- Co-authored-by: jrudz --- src/nomad_simulations/outputs.py | 220 ++++++++++++------- src/nomad_simulations/physical_property.py | 237 +++++++++++++++++++++ src/nomad_simulations/variables.py | 129 +++++++++++ tests/conftest.py | 49 +++++ tests/test_outputs.py | 68 ++++-- tests/test_physical_properties.py | 143 +++++++++++++ tests/test_template.py | 39 ---- tests/test_variables.py | 52 +++++ 8 files changed, 806 insertions(+), 131 deletions(-) create mode 100644 src/nomad_simulations/physical_property.py create mode 100644 src/nomad_simulations/variables.py create mode 100644 tests/test_physical_properties.py delete mode 100644 tests/test_template.py create mode 100644 tests/test_variables.py diff --git a/src/nomad_simulations/outputs.py b/src/nomad_simulations/outputs.py index cc42673a..4579c01e 100644 --- a/src/nomad_simulations/outputs.py +++ b/src/nomad_simulations/outputs.py @@ -17,18 +17,48 @@ # import numpy as np -from typing import Optional from structlog.stdlib import BoundLogger +from typing import Optional from nomad.datamodel.data import ArchiveSection +from nomad.metainfo import Quantity, SubSection, MEnum, Section, Context from nomad.datamodel.metainfo.annotations import ELNAnnotation -from nomad.metainfo import Quantity, SubSection, SectionProxy, Reference -from .atoms_state import AtomsState, OrbitalsState from .model_system import ModelSystem +from .physical_property import PhysicalProperty from .numerical_settings import SelfConsistency +class ElectronicBandGap(PhysicalProperty): + """ """ + + rank = [] + + type = Quantity( + type=MEnum('direct', 'indirect'), + description=""" + Type categorization of the electronic band gap. The electronic band gap can be `'direct'` or `'indirect'`. + """, + ) + + value = Quantity( + type=np.float64, + unit='joule', + description=""" + The value of the electronic band gap. + """, + ) + + # TODO add more functionalities here + + def __init__(self, m_def: Section = None, m_context: Context = None, **kwargs): + super().__init__(m_def, m_context, **kwargs) + self.name = self.m_def.name + + def normalize(self, archive, logger) -> None: + super().normalize(archive, logger) + + class Outputs(ArchiveSection): """ Output properties of a simulation. This base class can be used for inheritance in any of the output properties @@ -42,33 +72,6 @@ class Outputs(ArchiveSection): normalizer_level = 2 - name = Quantity( - type=str, - description=""" - Name of the output property. This is used for easier identification of the property and is connected - with the class name of each output property class, e.g., `'ElectronicBandGap'`, `'ElectronicBandStructure'`, etc. - """, - a_eln=ELNAnnotation(component='StringEditQuantity'), - ) - - orbitals_state_ref = Quantity( - type=OrbitalsState, - description=""" - Reference to the `OrbitalsState` section to which the output property references to and on - on which the simulation is performed. - """, - a_eln=ELNAnnotation(component='ReferenceEditQuantity'), - ) - - atoms_state_ref = Quantity( - type=AtomsState, - description=""" - Reference to the `AtomsState` section to which the output property references to and on - on which the simulation is performed. - """, - a_eln=ELNAnnotation(component='ReferenceEditQuantity'), - ) - model_system_ref = Quantity( type=ModelSystem, description=""" @@ -78,53 +81,29 @@ class Outputs(ArchiveSection): a_eln=ELNAnnotation(component='ReferenceEditQuantity'), ) - is_derived = Quantity( - type=bool, - default=False, - description=""" - Flag indicating whether the output property is derived from other output properties. We make - the distinction between directly parsed and derived output properties: - - Directly parsed: the output property is directly parsed from the simulation output files. - - Derived: the output property is derived from other output properties. No extra numerical settings - are required to calculate the output property. - """, - ) - - outputs_ref = Quantity( - type=Reference(SectionProxy('Outputs')), + custom_physical_property = SubSection( + sub_section=PhysicalProperty.m_def, + repeats=True, description=""" - Reference to the `Outputs` section from which the output property was derived. This is only - relevant if `is_derived` is set to True. + A custom physical property used to store properties not yet covered by the NOMAD schema. """, - a_eln=ELNAnnotation(component='ReferenceEditQuantity'), ) - def resolve_is_derived(self, outputs_ref) -> bool: - """ - Resolves if the output property is derived or not. - - Args: - outputs_ref (_type_): The reference to the `Outputs` section from which the output property was derived. - - Returns: - bool: The flag indicating whether the output property is derived or not. - """ - if outputs_ref is not None: - return True - return False + # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + # List of properties + # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + electronic_band_gap = SubSection(sub_section=ElectronicBandGap.m_def, repeats=True) def normalize(self, archive, logger) -> None: super().normalize(archive, logger) # Resolve if the output property `is_derived` or not. - self.is_derived = self.resolve_is_derived(self.outputs_ref) + # self.is_derived = self.resolve_is_derived(self.outputs_ref) class SCFOutputs(Outputs): """ - This section contains the self-consistent (SCF) steps performed to converge an output property, - as well as the information if the output property `is_converged` or not, depending on the - settings in the `SelfConsistency` base class defined in `numerical_settings.py`. + This section contains the self-consistent (SCF) steps performed to converge an output property. For simplicity, we contain the SCF steps of a simulation as part of the minimal workflow defined in NOMAD, the `SinglePoint`, i.e., we do not split each SCF step in its own entry. Thus, each `SinglePoint` @@ -140,23 +119,106 @@ class SCFOutputs(Outputs): """, ) - is_scf_converged = Quantity( - type=bool, - description=""" - Flag indicating whether the output property is converged or not after a SCF process. This quantity is connected - with `SelfConsistency` defined in the `numerical_settings.py` module. - """, - ) + def get_last_scf_steps_value( + self, + scf_last_steps: list, + property_name: str, + i_property: int, + scf_parameters: SelfConsistency, + logger: BoundLogger, + ) -> Optional[list]: + """ + Get the last two SCF values' magnitudes of a physical property and appends then in a list. - self_consistency_ref = Quantity( - type=SelfConsistency, - description=""" - Reference to the `SelfConsistency` section that defines the numerical settings to converge the - output property. - """, - ) + Args: + scf_last_steps (list): The list of the last two SCF steps. + property_name (str): The name of the physical property. + i_property (int): The index of the physical property. - # TODO add more functionality to automatically check convergence from `self_consistency_ref` and the last two `scf_steps` + Returns: + (Optional[list]): The list of the last two SCF values' magnitudes of a physical property. + """ + scf_values = [] + for step in scf_last_steps: + scf_phys_property = getattr(step, property_name)[i_property] + try: + if scf_phys_property.value.u != scf_parameters.threshold_change_unit: + logger.error( + f'The units of the `scf_step.{property_name}.value` does not coincide with the units of the `self_consistency_ref.threshold_unit`.' + ) + return [] + except Exception: + return [] + scf_values.append(scf_phys_property.value.magnitude) + return scf_values + + def resolve_is_scf_converged( + self, + property_name: str, + i_property: int, + phys_property: PhysicalProperty, + logger: BoundLogger, + ) -> Optional[bool]: + """ + Resolves if the physical property is converged or not after a SCF process. This is only ran + when there are at least two `scf_steps` elements. + + Returns: + (bool): The flag indicating whether the physical property is converged or not after a SCF process. + """ + # If there are not at least 2 `scf_steps`, return None + if len(self.scf_steps) < 2: + logger.warning('The SCF normalization needs at least two SCF steps.') + return None + scf_last_steps = self.scf_steps[-2:] + + # If there is not `self_consistency_ref` section, return None + scf_parameters = phys_property.self_consistency_ref + if scf_parameters is None: + return None + + # Extract the value.magnitude of the phys_property to be checked if converged or not + scf_values = self.get_last_scf_steps_value( + scf_last_steps, property_name, i_property, scf_parameters, logger + ) + if scf_values is None or len(scf_values) != 2: + logger.warning( + f'The SCF normalization could not resolve the SCF values for the property `{property_name}`.' + ) + return None + + # Compare with the `threshold_change` + scf_diff = abs(scf_values[0] - scf_values[1]) + threshold_change = scf_parameters.threshold_change + if scf_diff <= threshold_change: + return True + else: + logger.info( + f'The SCF process for the property `{property_name}` did not converge.' + ) + return False def normalize(self, archive, logger) -> None: super().normalize(archive, logger) + + # Resolve the `is_scf_converged` flag for all SCF obtained properties + for property_name in self.m_def.all_sub_sections.keys(): + # Skip the `scf_steps` and `custom_physical_property` sub-sections + if ( + property_name == 'scf_steps' + or property_name == 'custom_physical_property' + ): + continue + + # Check if the physical property with that property name is populated + phys_properties = getattr(self, property_name) + if phys_properties is None: + continue + if not isinstance(phys_properties, list): + phys_properties = [phys_properties] + + # Loop over the physical property of the same m_def type and set `is_scf_converged` + for i_property, phys_property in enumerate(phys_properties): + phys_property.is_scf_converged = self.resolve_is_scf_converged( + property_name, i_property, phys_property, logger + ) diff --git a/src/nomad_simulations/physical_property.py b/src/nomad_simulations/physical_property.py new file mode 100644 index 00000000..68e8b1c0 --- /dev/null +++ b/src/nomad_simulations/physical_property.py @@ -0,0 +1,237 @@ +# +# 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 Any, Optional + +from nomad import utils +from nomad.datamodel.data import ArchiveSection +from nomad.metainfo import ( + Quantity, + SubSection, + SectionProxy, + Reference, + Section, + Context, + MEnum, +) +from nomad.metainfo.metainfo import DirectQuantity, Dimension, _placeholder_quantity +from nomad.datamodel.metainfo.basesections import Entity + +from .variables import Variables +from .numerical_settings import SelfConsistency + + +# We add `logger` for the `PhysicalProperty.variables_shape` method +logger = utils.get_logger(__name__) + + +class PhysicalProperty(ArchiveSection): + """ + A base section used to define the physical properties obtained in a simulation, experiment, or in a post-processing + analysis. The main quantity of the `PhysicalProperty` is `value`, whose instantiation has to be overwritten in the derived classes + when inheriting from `PhysicalProperty`. It also contains `rank`, to define the tensor rank of the physical property, and + `variables`, to define the variables over which the physical property varies (see variables.py). This class can also store several + string identifiers and quantities for referencing and establishing the character of a physical property. + """ + + name = Quantity( + type=str, + description=""" + Name of the physical property. Example: `'ElectronicBandGap'`. + """, + ) + + source = Quantity( + type=MEnum('simulation', 'measurement', 'analysis'), + default='simulation', + description=""" + Source of the physical property. This quantity is related with the `Activity` performed to obtain the physical + property. Example: an `ElectronicBandGap` can be obtained from a `'simulation'` or in a `'measurement'`. + """, + ) + + type = Quantity( + type=str, + description=""" + Type categorization of the physical property. Example: an `ElectronicBandGap` can be `'direct'` + or `'indirect'`. + """, + # ! add more examples in the description to improve the understanding of this quantity + ) + + label = Quantity( + type=str, + description=""" + Label for additional classification of the physical property. Example: an `ElectronicBandGap` + can be labeled as `'DFT'` or `'GW'` depending on the methodology used to calculate it. + """, + # ! add more examples in the description to improve the understanding of this quantity + ) + + rank = DirectQuantity( + type=Dimension, + shape=['0..*'], + default=[], + name='rank', + description=""" + Rank of the tensor describing the physical property. This quantity is stored as a Dimension: + - scalars (tensor rank 0) have `rank=[]` (`len(rank) = 0`), + - vectors (tensor rank 1) have `rank=[a]` (`len(rank) = 1`), + - matrices (tensor rank 2), have `rank=[a, b]` (`len(rank) = 2`), + - etc. + """, + ) + + variables = SubSection(sub_section=Variables.m_def, repeats=True) + + # * `value` must be overwritten in the derived classes defining its type, unit, and description + value: Quantity = _placeholder_quantity + + entity_ref = Quantity( + type=Entity, + description=""" + Reference to the entity that the physical property refers to. Examples: + - a simulated physical property might refer to the macroscopic system or instead of a specific atom in the unit + cell. In the first case, `outputs.model_system_ref` (see outputs.py) will point to the `ModelSystem` section, + while in the second case, `entity_ref` will point to `AtomsState` section (see atoms_state.py). + """, + ) + + physical_property_ref = Quantity( + type=Reference(SectionProxy('PhysicalProperty')), + description=""" + Reference to the `PhysicalProperty` section from which the physical property was derived. If `physical_property_ref` + is populated, the quantity `is_derived` is set to True via normalization. + """, + ) + + is_derived = Quantity( + type=bool, + default=False, + description=""" + Flag indicating whether the physical property is derived from other physical properties. We make + the distinction between directly parsed and derived physical properties: + - Directly parsed: the physical property is directly parsed from the simulation output files. + - Derived: the physical property is derived from other physical properties. No extra numerical settings + are required to calculate the physical property. + """, + ) + + is_scf_converged = Quantity( + type=bool, + description=""" + Flag indicating whether the physical property is converged or not after a SCF process. This quantity is connected + with `SelfConsistency` defined in the `numerical_settings.py` module. + """, + ) + + self_consistency_ref = Quantity( + type=SelfConsistency, + description=""" + Reference to the `SelfConsistency` section that defines the numerical settings to converge the + physical property (see numerical_settings.py). + """, + ) + + @property + def variables_shape(self) -> Optional[list]: + """ + Shape of the variables over which the physical property varies. This is extracted from + `Variables.n_grid_points` and appended in a list. + + Example, a physical property which varies with `Temperature` and `ElectricField` will + return `variables_shape = [n_temperatures, n_electric_fields]`. + + Returns: + (list): The shape of the variables over which the physical property varies. + """ + if self.variables is not None: + return [v.get_n_grid_points(v.grid_points, logger) for v in self.variables] + return [] + + @property + def full_shape(self) -> list: + """ + Full shape of the physical property. This quantity is calculated as a concatenation of the `variables_shape` + and `rank`: + + `full_shape = variables_shape + rank` + + where `rank` is passed as an attribute of the `PhysicalProperty` and is related with the order of + the tensor of `value`, and `variables_shape` is obtained from the property-decorated function `variables_shape()` + and is related with the shapes of the `variables` over which the physical property varies. + + Example: a physical property which is a 3D vector and varies with `variables=[Temperature, ElectricField]` + will have `rank=[3]`, `variables_shape=[n_temperatures, n_electric_fields]`, and thus + `full_shape=[n_temperatures, n_electric_fields, 3]`. + + Returns: + (list): The full shape of the physical property. + """ + return self.variables_shape + self.rank + + def __init__(self, m_def: Section = None, m_context: Context = None, **kwargs): + super().__init__(m_def, m_context, **kwargs) + + # initialize a `_new_value` quantity copying the main attrs from the `_value` quantity (`type`, `unit`, + # `description`); this will then be used to setattr the `value` quantity to the `_new_value` one with the + # correct `shape=_full_shape` + for quant in self.m_def.quantities: + if quant.name == 'value': + self._new_value = Quantity( + type=quant.type, + unit=quant.unit, # ? this can be moved to __setattr__ + description=quant.description, + ) + break + + def __setattr__(self, name: str, val: Any) -> None: + # For the special case of `value`, its `shape` needs to be defined from `_full_shape` + if name == 'value': + # non-scalar or scalar `val` + try: + value_shape = list(val.shape) + except AttributeError: + value_shape = [] + + if value_shape != self.full_shape: + raise ValueError( + f'The shape of the stored `value` {value_shape} does not match the full shape {self.full_shape} ' + f'extracted from the variables `n_grid_points` and the `shape` defined in `PhysicalProperty`.' + ) + self._new_value.shape = self.full_shape + self._new_value = val.magnitude * val.u + return super().__setattr__(name, self._new_value) + return super().__setattr__(name, val) + + def _is_derived(self) -> bool: + """ + Resolves if the physical property is derived or not. + + Returns: + (bool): The flag indicating whether the physical property is derived or not. + """ + if self.physical_property_ref is not None: + return True + return False + + def normalize(self, archive, logger) -> None: + super().normalize(archive, logger) + + # Resolve if the physical property `is_derived` or not from another physical property. + self.is_derived = self._is_derived() diff --git a/src/nomad_simulations/variables.py b/src/nomad_simulations/variables.py new file mode 100644 index 00000000..3d18e584 --- /dev/null +++ b/src/nomad_simulations/variables.py @@ -0,0 +1,129 @@ +# +# 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. +# + +import numpy as np +from typing import Optional +from structlog.stdlib import BoundLogger + +from nomad.datamodel.data import ArchiveSection +from nomad.metainfo import Quantity, Section, Context + + +class Variables(ArchiveSection): + """ + Variables over which the physical property varies. These are defined as binned, i.e., discretized + values by `n_bins` and `bins`. These are used to calculate the `shape` of the physical property. + """ + + name = Quantity( + type=str, + default='Custom', + description=""" + Name of the variable. + """, + ) + + n_grid_points = Quantity( + type=int, + description=""" + Number of grid points in which the variable is discretized. + """, + ) + + grid_points = Quantity( + type=np.float64, + shape=['n_grid_points'], + description=""" + Grid points in which the variable is discretized. It might be overwritten with specific units. + """, + ) + + # grid_points_error = Quantity() + + def get_n_grid_points( + self, grid_points: Optional[list], logger: BoundLogger + ) -> Optional[int]: + """ + Get the number of grid points from the `grid_points` list. If `n_grid_points` is previously defined + and does not coincide with the length of `grid_points`, a warning is issued and this function re-assigns `n_grid_points` + as the length of `grid_points`. + + Args: + grid_points (Optional[list]): The grid points in which the variable is discretized. + logger (BoundLogger): The logger to log messages. + + Returns: + (Optional[int]): The number of grid points. + """ + if grid_points is not None and len(grid_points) > 0: + if ( + self.n_grid_points != len(grid_points) + and self.n_grid_points is not None + ): + logger.warning( + f'The stored `n_grid_points`, {self.n_grid_points}, does not coincide with the length of `grid_points`, ' + f'{len(grid_points)}. We will re-assign `n_grid_points` as the length of `grid_points`.' + ) + return len(grid_points) + return self.n_grid_points + + def normalize(self, archive, logger) -> None: + super().normalize(archive, logger) + + # Setting `n_grid_points` if these are not defined + self.n_grid_points = self.get_n_grid_points(self.grid_points, logger) + + +class Temperature(Variables): + """ """ + + grid_points = Quantity( + type=np.float64, + unit='kelvin', + shape=['n_grid_points'], + description=""" + Grid points in which the temperature is discretized. + """, + ) + + def __init__(self, m_def: Section = None, m_context: Context = None, **kwargs): + super().__init__(m_def, m_context, **kwargs) + self.name = self.m_def.name + + def normalize(self, archive, logger) -> None: + super().normalize(archive, logger) + + +class Energy(Variables): + """ """ + + grid_points = Quantity( + type=np.float64, + unit='joule', + shape=['n_grid_points'], + description=""" + Grid points in which the energy is discretized. + """, + ) + + def __init__(self, m_def: Section = None, m_context: Context = None, **kwargs): + super().__init__(m_def, m_context, **kwargs) + self.name = self.m_def.name + + def normalize(self, archive, logger) -> None: + super().normalize(archive, logger) diff --git a/tests/conftest.py b/tests/conftest.py index 9738ba39..7fea3b83 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,29 @@ +# +# 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. +# + import os import pytest +from nomad.units import ureg + +from nomad_simulations.outputs import ElectronicBandGap, Outputs, SCFOutputs +from nomad_simulations.numerical_settings import SelfConsistency + if os.getenv('_PYTEST_RAISE', '0') != '0': @pytest.hookimpl(tryfirst=True) @@ -10,3 +33,29 @@ def pytest_exception_interact(call): @pytest.hookimpl(tryfirst=True) def pytest_internalerror(excinfo): raise excinfo.value + + +def get_scf_electronic_band_gap_template(threshold_change: float = 1e-3) -> SCFOutputs: + scf_outputs = SCFOutputs() + # Define a list of scf_steps with values of the total energy like [1, 1.1, 1.11, 1.111, etc], + # such that the difference between one step and the next one decreases a factor of 10. + n_scf_steps = 5 + for i in range(1, n_scf_steps): + value = 1 + sum([1 / (10**j) for j in range(1, i + 1)]) + scf_step = Outputs( + electronic_band_gap=[ElectronicBandGap(value=value * ureg.joule)] + ) + scf_outputs.scf_steps.append(scf_step) + # Add a SCF calculated PhysicalProperty + scf_outputs.electronic_band_gap.append(ElectronicBandGap(value=value * ureg.joule)) + # and a `SelfConsistency` ref section + scf_params = SelfConsistency( + threshold_change=threshold_change, threshold_change_unit='joule' + ) + scf_outputs.electronic_band_gap[0].self_consistency_ref = scf_params + return scf_outputs + + +@pytest.fixture(scope='session') +def scf_electronic_band_gap() -> SCFOutputs: + return get_scf_electronic_band_gap_template() diff --git a/tests/test_outputs.py b/tests/test_outputs.py index 6bc26f7e..fac4489c 100644 --- a/tests/test_outputs.py +++ b/tests/test_outputs.py @@ -17,10 +17,30 @@ # import pytest +import numpy as np from . import logger +from .conftest import get_scf_electronic_band_gap_template -from nomad_simulations.outputs import Outputs +from nomad.units import ureg +from nomad.metainfo import Quantity +from nomad_simulations.physical_property import PhysicalProperty +from nomad_simulations.numerical_settings import SelfConsistency +from nomad_simulations.outputs import Outputs, SCFOutputs, ElectronicBandGap + + +class TotalEnergy(PhysicalProperty): + """Physical property class defined for testing purposes.""" + + rank = [] + variables = [] + value = Quantity( + type=np.float64, + unit='joule', + description=""" + The total energy of the system. + """, + ) class TestOutputs: @@ -29,18 +49,40 @@ class TestOutputs: """ @pytest.mark.parametrize( - 'outputs_ref, result', - [ - (Outputs(), True), - (None, False), - ], + 'threshold_change, result', + [(1e-3, True), (1e-5, False)], + ) + def test_is_scf_converged(self, threshold_change: float, result: bool): + """ + Test the `resolve_is_scf_converged` method. + """ + scf_outputs = get_scf_electronic_band_gap_template( + threshold_change=threshold_change + ) + is_scf_converged = scf_outputs.resolve_is_scf_converged( + property_name='electronic_band_gap', + i_property=0, + phys_property=scf_outputs.electronic_band_gap[0], + logger=logger, + ) + assert is_scf_converged == result + + @pytest.mark.parametrize( + 'threshold_change, result', + [(1e-3, True), (1e-5, False)], ) - def test_normalize(self, outputs_ref, result): + def test_normalize(self, threshold_change: float, result: bool): """ - Test the `normalize` and `resolve_is_derived` methods. + Test the `normalize` method. """ - outputs = Outputs() - assert outputs.resolve_is_derived(outputs_ref) == result - outputs.outputs_ref = outputs_ref - outputs.normalize(None, logger) - assert outputs.is_derived == result + scf_outputs = get_scf_electronic_band_gap_template( + threshold_change=threshold_change + ) + # Add a non-SCF calculated PhysicalProperty + scf_outputs.custom_physical_property = [ + TotalEnergy(name='TotalEnergy', value=1 * ureg.joule) + ] + + scf_outputs.normalize(None, logger) + assert scf_outputs.electronic_band_gap[0].is_scf_converged == result + assert scf_outputs.custom_physical_property[0].is_scf_converged is None diff --git a/tests/test_physical_properties.py b/tests/test_physical_properties.py new file mode 100644 index 00000000..b96c3660 --- /dev/null +++ b/tests/test_physical_properties.py @@ -0,0 +1,143 @@ +# +# 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. +# + +import numpy as np +import pytest + +from . import logger + +from nomad.units import ureg +from nomad.metainfo import Quantity + +from nomad_simulations.variables import Variables +from nomad_simulations.physical_property import PhysicalProperty + + +class DummyPhysicalProperty(PhysicalProperty): + value = Quantity( + type=np.float64, + unit='eV', + description=""" + This value is defined in order to test the `__setattr__` method in `PhysicalProperty`. + """, + ) + + +class TestPhysicalProperty: + """ + Test the `PhysicalProperty` class defined in `physical_property.py`. + """ + + @pytest.mark.parametrize( + 'rank, variables, result_variables_shape, result_full_shape', + [ + ([], [], [], []), + ([3], [], [], [3]), + ([3, 3], [], [], [3, 3]), + ([], [Variables(n_grid_points=4)], [4], [4]), + ([3], [Variables(n_grid_points=4)], [4], [4, 3]), + ([3, 3], [Variables(n_grid_points=4)], [4], [4, 3, 3]), + ( + [], + [Variables(n_grid_points=4), Variables(n_grid_points=10)], + [4, 10], + [4, 10], + ), + ( + [3], + [Variables(n_grid_points=4), Variables(n_grid_points=10)], + [4, 10], + [4, 10, 3], + ), + ( + [3, 3], + [Variables(n_grid_points=4), Variables(n_grid_points=10)], + [4, 10], + [4, 10, 3, 3], + ), + ], + ) + def test_static_properties( + self, + rank: list, + variables: list, + result_variables_shape: list, + result_full_shape: list, + ): + """ + Test the static properties of the `PhysicalProperty` class, `variables_shape` and `full_shape`. + """ + physical_property = PhysicalProperty( + source='simulation', + rank=rank, + variables=variables, + ) + assert physical_property.variables_shape == result_variables_shape + assert physical_property.full_shape == result_full_shape + + def test_setattr_value(self): + """ + Test the `__setattr__` method when setting the `value` quantity of a physical property. + """ + physical_property = DummyPhysicalProperty( + source='simulation', + rank=[3, 3], + variables=[Variables(n_grid_points=4), Variables(n_grid_points=10)], + ) + # `physical_property.value` must have full_shape=[4, 10, 3, 3] + value = np.ones((4, 10, 3, 3)) * ureg.eV + assert physical_property.full_shape == list(value.shape) + physical_property.value = value + assert np.all(physical_property.value == value) + + def test_setattr_value_wrong_shape(self): + """ + Test the `__setattr__` method when the `value` has a wrong shape. + """ + physical_property = PhysicalProperty( + source='simulation', + rank=[], + variables=[], + ) + # `physical_property.value` must have shape=[] + value = np.ones((3, 3)) + wrong_shape = list(value.shape) + with pytest.raises(ValueError) as exc_info: + physical_property.value = value + assert ( + str(exc_info.value) + == f'The shape of the stored `value` {wrong_shape} does not match the full shape {physical_property.full_shape} extracted from the variables `n_grid_points` and the `shape` defined in `PhysicalProperty`.' + ) + + def test_is_derived(self): + """ + Test the `normalize` and `_is_derived` methods. + """ + # Testing a directly parsed physical property + not_derived_physical_property = PhysicalProperty(source='simulation') + assert not_derived_physical_property._is_derived() is False + not_derived_physical_property.normalize(None, logger) + assert not_derived_physical_property.is_derived is False + # Testing a derived physical property + derived_physical_property = PhysicalProperty( + source='analysis', + physical_property_ref=not_derived_physical_property, + ) + assert derived_physical_property._is_derived() is True + derived_physical_property.normalize(None, logger) + assert derived_physical_property.is_derived is True diff --git a/tests/test_template.py b/tests/test_template.py deleted file mode 100644 index 4d56c854..00000000 --- a/tests/test_template.py +++ /dev/null @@ -1,39 +0,0 @@ -# -# 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. -# - -import pytest - -from nomad.utils import get_logger -from nomad.datamodel import EntryArchive, EntryMetadata - - -def approx(value, abs=0, rel=1e-6): - return pytest.approx(value, abs=abs, rel=rel) - - -LOGGER = get_logger(__name__) - - -def run_parsing(parser_class, filepath): - archive = EntryArchive(metadata=EntryMetadata()) - parser_class().parse(filepath, archive, LOGGER) - return archive - - -def test_dummy(): - assert True diff --git a/tests/test_variables.py b/tests/test_variables.py new file mode 100644 index 00000000..df4fbaab --- /dev/null +++ b/tests/test_variables.py @@ -0,0 +1,52 @@ +# +# 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. +# + +import pytest + +from . import logger + +from nomad_simulations.variables import Variables + + +class TestVariables: + """ + Test the `Variables` class defined in `variables.py`. + """ + + @pytest.mark.parametrize( + 'n_grid_points, grid_points, result', + [ + (3, [-1, 0, 1], 3), + (5, [-1, 0, 1], 3), + (None, [-1, 0, 1], 3), + (4, None, 4), + (4, [], 4), + ], + ) + def test_normalize(self, n_grid_points: int, grid_points: list, result: int): + """ + Test the `normalize` and `get_n_grid_points` methods. + """ + variable = Variables( + name='variable_1', + n_grid_points=n_grid_points, + grid_points=grid_points, + ) + assert variable.get_n_grid_points(grid_points, logger) == result + variable.normalize(None, logger) + assert variable.n_grid_points == result