From 95286c4f62a2c8b85ebce5d3b1ff3197ac8320ef Mon Sep 17 00:00:00 2001 From: "Jose M. Pizarro" <112697669+JosePizarro3@users.noreply.github.com> Date: Thu, 18 Apr 2024 10:46:47 +0200 Subject: [PATCH] Add BandGap output (#53) * Moved electronic band gap definition to subfolder properties/ * Improved ElectronicBandGap property Added iri to quantity in PhysicalProperty * Added testing for ElectronicBandGap Fix __setattr__ in PhysicalProperty * Added extract_spin_polarized_property to Outputs and testing * Changed negative value of ElectronicBandGap to be set to None Added raise ValueError in PhysicalProperty when value is None * Fix logic for momentum transfer and resolve_type * Change variables.Energy to Energy2 to avoid bugs with Normalizers (to be fixed) --- src/nomad_simulations/outputs.py | 57 +++---- src/nomad_simulations/physical_property.py | 51 ++++-- src/nomad_simulations/properties/__init__.py | 19 +++ src/nomad_simulations/properties/band_gap.py | 157 +++++++++++++++++++ src/nomad_simulations/variables.py | 11 +- tests/conftest.py | 3 +- tests/test_band_gap.py | 129 +++++++++++++++ tests/test_outputs.py | 28 +++- tests/test_physical_properties.py | 16 ++ 9 files changed, 420 insertions(+), 51 deletions(-) create mode 100644 src/nomad_simulations/properties/__init__.py create mode 100644 src/nomad_simulations/properties/band_gap.py create mode 100644 tests/test_band_gap.py diff --git a/src/nomad_simulations/outputs.py b/src/nomad_simulations/outputs.py index 4579c01e..d0ea0868 100644 --- a/src/nomad_simulations/outputs.py +++ b/src/nomad_simulations/outputs.py @@ -16,47 +16,17 @@ # limitations under the License. # -import numpy as np 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.metainfo import Quantity, SubSection from nomad.datamodel.metainfo.annotations import ELNAnnotation 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) +from .properties import ElectronicBandGap class Outputs(ArchiveSection): @@ -94,12 +64,29 @@ class Outputs(ArchiveSection): # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # electronic_band_gap = SubSection(sub_section=ElectronicBandGap.m_def, repeats=True) + def extract_spin_polarized_property(self, property_name: str) -> list: + """ + Extracts the spin-polarized properties if present from the property name and returns them as a list of two elements in + which each element refers to each `spin_channel`. If the return list is empty, it means that the simulation is not + spin-polarized (i.e., `spin_channel` is not defined). + + Args: + property_name (str): The name of the property to be extracted. + + Returns: + (list): The list of spin-polarized properties. + """ + spin_polarized_properties = [] + properties = getattr(self, property_name) + for prop in properties: + if prop.spin_channel is None: + continue + spin_polarized_properties.append(prop) + return spin_polarized_properties + 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) - class SCFOutputs(Outputs): """ diff --git a/src/nomad_simulations/physical_property.py b/src/nomad_simulations/physical_property.py index 68e8b1c0..2dc3f7ec 100644 --- a/src/nomad_simulations/physical_property.py +++ b/src/nomad_simulations/physical_property.py @@ -16,6 +16,7 @@ # limitations under the License. # +import numpy as np from typing import Any, Optional from nomad import utils @@ -28,6 +29,7 @@ Section, Context, MEnum, + URL, ) from nomad.metainfo.metainfo import DirectQuantity, Dimension, _placeholder_quantity from nomad.datamodel.metainfo.basesections import Entity @@ -56,6 +58,14 @@ class PhysicalProperty(ArchiveSection): """, ) + iri = Quantity( + type=URL, + description=""" + Internationalized Resource Identifier (IRI) of the physical property defined in the FAIRmat + taxonomy, https://fairmat-nfdi.github.io/fairmat-taxonomy/. + """, + ) + source = Quantity( type=MEnum('simulation', 'measurement', 'analysis'), default='simulation', @@ -185,24 +195,42 @@ def full_shape(self) -> list: """ 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) + @property + def _new_value(self) -> Quantity: + """ + Initialize a new `Quantity` object for the `value` quantity with the correct `shape` extracted from + the `full_shape` attribute. This copies the main attributes from `value` (`type`, `description`, `unit`). + It is used in the `__setattr__` method. - # 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` + Returns: + (Quantity): The new `Quantity` object for setting the `value` quantity. + """ for quant in self.m_def.quantities: if quant.name == 'value': - self._new_value = Quantity( + return Quantity( type=quant.type, unit=quant.unit, # ? this can be moved to __setattr__ description=quant.description, ) - break + + def __init__( + self, m_def: Section = None, m_context: Context = None, **kwargs + ) -> None: + super().__init__(m_def, m_context, **kwargs) 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': + if val is None: + raise ValueError( + f'The value of the physical property {self.name} is None. Please provide a finite valid value.' + ) + _new_value = self._new_value + + # patch for when `val` does not have units and it is passed as a list (instead of np.array) + if isinstance(val, list): + val = np.array(val) + # non-scalar or scalar `val` try: value_shape = list(val.shape) @@ -214,9 +242,12 @@ def __setattr__(self, name: str, val: Any) -> None: 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) + _new_value.shape = self.full_shape + if hasattr(val, 'magnitude'): + _new_value = val.magnitude * val.u + else: + _new_value = val + return super().__setattr__(name, _new_value) return super().__setattr__(name, val) def _is_derived(self) -> bool: diff --git a/src/nomad_simulations/properties/__init__.py b/src/nomad_simulations/properties/__init__.py new file mode 100644 index 00000000..29011f91 --- /dev/null +++ b/src/nomad_simulations/properties/__init__.py @@ -0,0 +1,19 @@ +# +# 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 .band_gap import ElectronicBandGap diff --git a/src/nomad_simulations/properties/band_gap.py b/src/nomad_simulations/properties/band_gap.py new file mode 100644 index 00000000..87a9dd02 --- /dev/null +++ b/src/nomad_simulations/properties/band_gap.py @@ -0,0 +1,157 @@ +# +# 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 structlog.stdlib import BoundLogger +import pint +from typing import Optional + +from nomad.metainfo import Quantity, MEnum, Section, Context + +from ..physical_property import PhysicalProperty + + +class ElectronicBandGap(PhysicalProperty): + """ + Energy difference between the highest occupied electronic state and the lowest unoccupied electronic state. + """ + + iri = 'http://fairmat-nfdi.eu/taxonomy/ElectronicBandGap' + + # ? can `type` change character depending on the `variables`? + type = Quantity( + type=MEnum('direct', 'indirect'), + description=""" + Type categorization of the electronic band gap. This quantity is directly related with `momentum_transfer` as by + definition, the electronic band gap is `'direct'` for zero momentum transfer (or if `momentum_transfer` is `None`) and `'indirect'` + for finite momentum transfer. + + Note: in the case of finite `variables`, this quantity refers to all of the `value` in the array. + """, + ) + + momentum_transfer = Quantity( + type=np.float64, + shape=[2, 3], + description=""" + If the electronic band gap is `'indirect'`, the reciprocal momentum transfer for which the band gap is defined + in units of the `reciprocal_lattice_vectors`. The initial and final momentum 3D vectors are given in the first + and second element. Example, the momentum transfer in bulk Si2 happens between the Γ and the (approximately) + X points in the Brillouin zone; thus: + `momentum_transfer = [[0, 0, 0], [0.5, 0.5, 0]]`. + + Note: this quantity only refers to scalar `value`, not to arrays of `value`. + """, + ) + + spin_channel = Quantity( + type=np.int32, + description=""" + Spin channel of the corresponding electronic band gap. It can take values of 0 or 1. + """, + ) + + value = Quantity( + type=np.float64, + unit='joule', + description=""" + The value of the electronic band gap. This value has to be positive, otherwise it will + prop an error and be set to None by the `normalize()` function. + """, + ) + + def __init__( + self, m_def: Section = None, m_context: Context = None, **kwargs + ) -> None: + super().__init__(m_def, m_context, **kwargs) + self.name = self.m_def.name + self.rank = [] + + def check_negative_values(self, logger: BoundLogger) -> Optional[pint.Quantity]: + """ + Checks if the electronic band gaps is negative and sets them to None if they are. + + Args: + logger (BoundLogger): The logger to log messages. + """ + value = self.value.magnitude + if not isinstance(self.value.magnitude, np.ndarray): # for scalars + value = np.array( + [value] + ) # ! check this when talking with Lauri and Theodore + + # Set the value to 0 when it is negative + if (value < 0).any(): + logger.error('The electronic band gap cannot be defined negative.') + return None + + if not isinstance(self.value.magnitude, np.ndarray): # for scalars + value = value[0] + return value * self.value.u + + def resolve_type(self, logger: BoundLogger) -> Optional[str]: + """ + Resolves the `type` of the electronic band gap based on the stored `momentum_transfer` values. + + Args: + logger (BoundLogger): The logger to log messages. + + Returns: + (Optional[str]): The resolved `type` of the electronic band gap. + """ + mtr = self.momentum_transfer if self.momentum_transfer is not None else [] + + # Check if the `momentum_transfer` is [], and return the type and a warning in the log for `indirect` band gaps + if len(mtr) == 0: + if self.type == 'indirect': + logger.warning( + 'The `momentum_transfer` is not stored for an `indirect` band gap.' + ) + return self.type + + # Check if the `momentum_transfer` has at least two elements, and return None if it does not + if len(mtr) == 1: + logger.warning( + 'The `momentum_transfer` should have at least two elements so that the difference can be calculated and the type of electronic band gap can be resolved.' + ) + return None + + # Resolve `type` from the difference between the initial and final momentum transfer + momentum_difference = np.diff(mtr, axis=0) + if (np.isclose(momentum_difference, np.zeros(3))).all(): + return 'direct' + else: + return 'indirect' + + def normalize(self, archive, logger) -> None: + super().normalize(archive, logger) + + # Checks if the `value` is negative and sets it to None if it is. + self.value = self.check_negative_values(logger) + if self.value is None: + # ? What about deleting the class if `value` is None? + logger.error('The `value` of the electronic band gap is not stored.') + return + + # Resolve the `type` of the electronic band gap from `momentum_transfer`, ONLY for scalar `value` + if isinstance(self.value.magnitude, np.ndarray): + logger.info( + 'We do not support `type` which describe individual elements in an array `value`.' + ) + else: + self.type = self.resolve_type(logger) diff --git a/src/nomad_simulations/variables.py b/src/nomad_simulations/variables.py index 3d18e584..28c40a70 100644 --- a/src/nomad_simulations/variables.py +++ b/src/nomad_simulations/variables.py @@ -101,7 +101,9 @@ class Temperature(Variables): """, ) - def __init__(self, m_def: Section = None, m_context: Context = None, **kwargs): + def __init__( + self, m_def: Section = None, m_context: Context = None, **kwargs + ) -> None: super().__init__(m_def, m_context, **kwargs) self.name = self.m_def.name @@ -109,7 +111,8 @@ def normalize(self, archive, logger) -> None: super().normalize(archive, logger) -class Energy(Variables): +# ! This needs to be fixed as it gives errors when running normalizers with conflicting names (ask Area D) +class Energy2(Variables): """ """ grid_points = Quantity( @@ -121,7 +124,9 @@ class Energy(Variables): """, ) - def __init__(self, m_def: Section = None, m_context: Context = None, **kwargs): + def __init__( + self, m_def: Section = None, m_context: Context = None, **kwargs + ) -> None: super().__init__(m_def, m_context, **kwargs) self.name = self.m_def.name diff --git a/tests/conftest.py b/tests/conftest.py index 7fea3b83..f0dd8e07 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,8 +21,9 @@ from nomad.units import ureg -from nomad_simulations.outputs import ElectronicBandGap, Outputs, SCFOutputs +from nomad_simulations.outputs import Outputs, SCFOutputs from nomad_simulations.numerical_settings import SelfConsistency +from nomad_simulations.properties import ElectronicBandGap if os.getenv('_PYTEST_RAISE', '0') != '0': diff --git a/tests/test_band_gap.py b/tests/test_band_gap.py new file mode 100644 index 00000000..88ea1d47 --- /dev/null +++ b/tests/test_band_gap.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 pytest +import numpy as np +from typing import Optional, List, Union + +from . import logger + +from nomad.units import ureg +from nomad_simulations.properties import ElectronicBandGap +from nomad_simulations.variables import Temperature + + +class TestElectronicBandGap: + """ + Test the `ElectronicBandGap` class defined in `properties/band_gap.py`. + """ + + # ! Include this initial `test_default_quantities` method when testing your PhysicalProperty classes + def test_default_quantities(self): + """ + Test the default quantities assigned when creating an instance of the `ElectronicBandGap` class. + """ + electronic_band_gap = ElectronicBandGap() + assert ( + electronic_band_gap.iri + == 'http://fairmat-nfdi.eu/taxonomy/ElectronicBandGap' + ) + assert electronic_band_gap.name == 'ElectronicBandGap' + assert electronic_band_gap.rank == [] + + @pytest.mark.parametrize( + 'value, result', + [ + (0.0, 0.0), + (1.0, 1.0), + (-1.0, None), + ([1.0, 2.0, -1.0], None), + ], + ) + def test_check_negative_values( + self, value: Union[List[float], float], result: float + ): + """ + Test the `check_negative_values` method. + """ + if isinstance(value, list): + electronic_band_gap = ElectronicBandGap( + variables=[Temperature(grid_points=[1, 2, 3] * ureg.kelvin)] + ) + else: + electronic_band_gap = ElectronicBandGap() + electronic_band_gap.value = value * ureg.joule + checked_value = electronic_band_gap.check_negative_values(logger) + if checked_value is not None: + assert np.isclose(checked_value.magnitude, result) + else: + assert checked_value == result + + @pytest.mark.parametrize( + 'momentum_transfer, type, result', + [ + (None, None, None), + (None, 'direct', 'direct'), + (None, 'indirect', 'indirect'), + ([], None, None), + ([], 'direct', 'direct'), + ([], 'indirect', 'indirect'), + ([[0, 0, 0]], None, None), + ([[0, 0, 0]], 'direct', None), + ([[0, 0, 0]], 'indirect', None), + ([[0, 0, 0], [0, 0, 0]], None, 'direct'), + ([[0, 0, 0], [0, 0, 0]], 'direct', 'direct'), + ([[0, 0, 0], [0, 0, 0]], 'indirect', 'direct'), + ([[0, 0, 0], [0.5, 0.5, 0.5]], None, 'indirect'), + ([[0, 0, 0], [0.5, 0.5, 0.5]], 'direct', 'indirect'), + ([[0, 0, 0], [0.5, 0.5, 0.5]], 'indirect', 'indirect'), + ], + ) + def test_resolve_type( + self, momentum_transfer: Optional[List[float]], type: str, result: Optional[str] + ): + """ + Test the `resolve_type` method. + """ + electronic_band_gap = ElectronicBandGap( + variables=[], + momentum_transfer=momentum_transfer, + type=type, + ) + assert electronic_band_gap.resolve_type(logger) == result + + def test_normalize(self): + """ + Test the `normalize` method for two different ElectronicBandGap instantiations, one with a scalar + `value` and another with a temperature-dependent `value` + """ + scalar_band_gap = ElectronicBandGap(variables=[], type='direct') + scalar_band_gap.value = 1.0 * ureg.joule + scalar_band_gap.normalize(None, logger) + assert scalar_band_gap.type == 'direct' + assert np.isclose(scalar_band_gap.value.magnitude, 1.0) + + t_dependent_band_gap = ElectronicBandGap( + variables=[Temperature(grid_points=[0, 10, 20, 30] * ureg.kelvin)], + type='direct', + ) + t_dependent_band_gap.value = [1.0, 2.0, 3.0, 4.0] * ureg.joule + t_dependent_band_gap.normalize(None, logger) + assert t_dependent_band_gap.type == 'direct' + assert ( + np.isclose(t_dependent_band_gap.value.magnitude, [1.0, 2.0, 3.0, 4.0]) + ).all() diff --git a/tests/test_outputs.py b/tests/test_outputs.py index fac4489c..5e5befa7 100644 --- a/tests/test_outputs.py +++ b/tests/test_outputs.py @@ -25,8 +25,7 @@ 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 +from nomad_simulations.outputs import Outputs, ElectronicBandGap class TotalEnergy(PhysicalProperty): @@ -67,6 +66,31 @@ def test_is_scf_converged(self, threshold_change: float, result: bool): ) assert is_scf_converged == result + def test_extract_spin_polarized_properties(self): + """ + Test the `extract_spin_polarized_property` method. + """ + outputs = Outputs() + + # No spin-polarized band gap + band_gap_non_spin_polarized = ElectronicBandGap(variables=[]) + band_gap_non_spin_polarized.value = 2.0 * ureg.joule + outputs.electronic_band_gap.append(band_gap_non_spin_polarized) + band_gaps = outputs.extract_spin_polarized_property('electronic_band_gap') + assert band_gaps == [] + + # Spin-polarized band gaps + band_gap_spin_1 = ElectronicBandGap(variables=[], spin_channel=0) + band_gap_spin_1.value = 1.0 * ureg.joule + outputs.electronic_band_gap.append(band_gap_spin_1) + band_gap_spin_2 = ElectronicBandGap(variables=[], spin_channel=1) + band_gap_spin_2.value = 1.5 * ureg.joule + outputs.electronic_band_gap.append(band_gap_spin_2) + band_gaps = outputs.extract_spin_polarized_property('electronic_band_gap') + assert len(band_gaps) == 2 + assert band_gaps[0].value.magnitude == 1.0 + assert band_gaps[1].value.magnitude == 1.5 + @pytest.mark.parametrize( 'threshold_change, result', [(1e-3, True), (1e-5, False)], diff --git a/tests/test_physical_properties.py b/tests/test_physical_properties.py index b96c3660..59c47231 100644 --- a/tests/test_physical_properties.py +++ b/tests/test_physical_properties.py @@ -124,6 +124,22 @@ def test_setattr_value_wrong_shape(self): == 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_setattr_none(self): + """ + Test the `__setattr__` method when setting the `value` to `None`. + """ + physical_property = PhysicalProperty( + source='simulation', + rank=[], + variables=[], + ) + with pytest.raises(ValueError) as exc_info: + physical_property.value = None + assert ( + str(exc_info.value) + == f'The value of the physical property {physical_property.name} is None. Please provide a finite valid value.' + ) + def test_is_derived(self): """ Test the `normalize` and `_is_derived` methods.