diff --git a/.gitignore b/.gitignore index 0702479d..e744d987 100644 --- a/.gitignore +++ b/.gitignore @@ -162,6 +162,7 @@ cython_debug/ # VSCode settings .vscode/launch.json +.vscode/settings.json # comments scripts comments.py diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index fbb1c885..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "python.defaultInterpreterPath": ".pyenv/bin/python", - "python.terminal.activateEnvInCurrentTerminal": true, - "editor.rulers": [90], - "editor.renderWhitespace": "all", - "editor.tabSize": 4, - "[javascript]": { - "editor.tabSize": 2 - }, - "files.trimTrailingWhitespace": true, - "files.watcherExclude": { - "${workspaceFolder}/.pyenv/**": true - }, - "files.exclude": { - "\"**/*.pyc\": {\"when\": \"$(basename).py\"}": true, - "**/__pycache__": true, - "**/node_modules": true - }, - "python.testing.pytestPath": "pytest", - "python.testing.pytestArgs": ["tests"], - "python.testing.unittestEnabled": false, - "editor.defaultFormatter": "charliermarsh.ruff", - "editor.formatOnSave": true -} diff --git a/src/nomad_simulations/atoms_state.py b/src/nomad_simulations/atoms_state.py index 1b810c84..392f4edd 100644 --- a/src/nomad_simulations/atoms_state.py +++ b/src/nomad_simulations/atoms_state.py @@ -160,15 +160,15 @@ def __init__(self, m_def: Section = None, m_context: Context = None, **kwargs): 'ms_numbers': dict((zip(('down', 'up'), (-0.5, 0.5)))), } - def _check_quantum_numbers(self, logger: BoundLogger) -> bool: + def validate_quantum_numbers(self, logger: BoundLogger) -> bool: """ - Checks the physicality of the quantum numbers. + Validate the quantum numbers (`n`, `l`, `ml`, `ms`) by checking if they are physically sensible. Args: logger (BoundLogger): The logger to log messages. Returns: - (bool): True if the quantum numbers are physical, False otherwise. + (bool): True if the quantum numbers are physically sensible, False otherwise. """ if self.n_quantum_number is not None and self.n_quantum_number < 1: logger.error('The `n_quantum_number` must be greater than 0.') @@ -293,7 +293,7 @@ def normalize(self, archive, logger) -> None: super().normalize(archive, logger) # General checks for physical quantum numbers and symbols - if not self._check_quantum_numbers(logger): + if not self.validate_quantum_numbers(logger): logger.error('The quantum numbers are not physical.') return diff --git a/src/nomad_simulations/numerical_settings.py b/src/nomad_simulations/numerical_settings.py index e2a2f5eb..b28843d5 100644 --- a/src/nomad_simulations/numerical_settings.py +++ b/src/nomad_simulations/numerical_settings.py @@ -20,7 +20,7 @@ import pint from itertools import accumulate, tee, chain from structlog.stdlib import BoundLogger -from typing import Optional, List, Tuple, Union, Dict +from typing import Optional, List, Tuple, Union from ase.dft.kpoints import monkhorst_pack, get_monkhorst_pack_size_and_offset from nomad.units import ureg @@ -165,7 +165,7 @@ class KSpaceFunctionalities: A functionality class useful for defining methods shared between `KSpace`, `KMesh`, and `KLinePath`. """ - def _check_reciprocal_lattice_vectors( + def validate_reciprocal_lattice_vectors( self, reciprocal_lattice_vectors: Optional[pint.Quantity], logger: BoundLogger, @@ -173,7 +173,7 @@ def _check_reciprocal_lattice_vectors( grid: Optional[List[int]] = [], ) -> bool: """ - Check if the `reciprocal_lattice_vectors` exist and if they have the same dimensionality as `grid`. + Validate the `reciprocal_lattice_vectors` by checking if they exist and if they have the same dimensionality as `grid`. Args: reciprocal_lattice_vectors (Optional[pint.Quantity]): The reciprocal lattice vectors of the atomic cell. @@ -404,7 +404,7 @@ def get_k_line_density( (np.float64): The k-line density of the `KMesh`. """ # Initial check - if not KSpaceFunctionalities()._check_reciprocal_lattice_vectors( + if not KSpaceFunctionalities().validate_reciprocal_lattice_vectors( reciprocal_lattice_vectors=reciprocal_lattice_vectors, logger=logger, check_grid=True, @@ -438,7 +438,7 @@ def resolve_k_line_density( (Optional[pint.Quantity]): The resolved `k_line_density` of the `KMesh`. """ # Initial check - if not KSpaceFunctionalities()._check_reciprocal_lattice_vectors( + if not KSpaceFunctionalities().validate_reciprocal_lattice_vectors( reciprocal_lattice_vectors=reciprocal_lattice_vectors, logger=logger, check_grid=True, @@ -553,7 +553,7 @@ def resolve_high_symmetry_path_values( (Optional[List[float]]): The resolved `high_symmetry_path_values`. """ # Initial check on the `reciprocal_lattice_vectors` - if not KSpaceFunctionalities()._check_reciprocal_lattice_vectors( + if not KSpaceFunctionalities().validate_reciprocal_lattice_vectors( reciprocal_lattice_vectors=reciprocal_lattice_vectors, logger=logger ): return [] @@ -576,9 +576,9 @@ def resolve_high_symmetry_path_values( ] return high_symmetry_path_values - def _check_high_symmetry_path(self, logger: BoundLogger) -> bool: + def validate_high_symmetry_path(self, logger: BoundLogger) -> bool: """ - Check if the `high_symmetry_path_names` and `high_symmetry_path_values` are defined and have the same length. + Validate `high_symmetry_path_names` and `high_symmetry_path_values` by checking if they are defined and have the same length. Args: logger (BoundLogger): The logger to log messages. @@ -624,7 +624,7 @@ def get_high_symmetry_path_norms( `high_symmetry_path_value_norms = [0, 0.5, 0.5 + 1 / np.sqrt(2), 1 + 1 / np.sqrt(2)]` """ # Checking the high symmetry path quantities - if not self._check_high_symmetry_path(logger): + if not self.validate_high_symmetry_path(logger): return None # Checking if `reciprocal_lattice_vectors` is defined and taking its magnitude to operate if reciprocal_lattice_vectors is None: @@ -672,7 +672,7 @@ def resolve_points( logger (BoundLogger): The logger to log messages. """ # General checks for quantities - if not self._check_high_symmetry_path(logger): + if not self.validate_high_symmetry_path(logger): return None if reciprocal_lattice_vectors is None: logger.warning( @@ -755,7 +755,7 @@ def normalize(self, archive, logger) -> None: ) # If `high_symmetry_path` is not defined, we do not normalize the KLinePath - if not self._check_high_symmetry_path(logger): + if not self.validate_high_symmetry_path(logger): return @@ -803,8 +803,9 @@ def resolve_reciprocal_lattice_vectors( # General checks to proceed with normalization if is_not_representative(model_system, logger): continue + # TODO extend this for other dimensions (@ndaelman-hu) - if model_system.type != 'bulk': + if model_system.type is not None and model_system.type != 'bulk': logger.warning('`ModelSystem.type` is not describing a bulk system.') continue diff --git a/src/nomad_simulations/outputs.py b/src/nomad_simulations/outputs.py index 46436a6f..42e533fe 100644 --- a/src/nomad_simulations/outputs.py +++ b/src/nomad_simulations/outputs.py @@ -33,6 +33,9 @@ HoppingMatrix, ElectronicBandGap, ElectronicDensityOfStates, + ElectronicEigenvalues, + FermiSurface, + ElectronicBandStructure, AbsorptionSpectrum, XASSpectrum, Permittivity, @@ -75,12 +78,22 @@ class Outputs(ArchiveSection): hopping_matrices = SubSection(sub_section=HoppingMatrix.m_def, repeats=True) + electronic_eigenvalues = SubSection( + sub_section=ElectronicEigenvalues.m_def, repeats=True + ) + electronic_band_gaps = SubSection(sub_section=ElectronicBandGap.m_def, repeats=True) electronic_dos = SubSection( sub_section=ElectronicDensityOfStates.m_def, repeats=True ) + fermi_surfaces = SubSection(sub_section=FermiSurface.m_def, repeats=True) + + electronic_band_structures = SubSection( + sub_section=ElectronicBandStructure.m_def, repeats=True + ) + permittivities = SubSection(sub_section=Permittivity.m_def, repeats=True) absorption_spectra = SubSection(sub_section=AbsorptionSpectrum.m_def, repeats=True) diff --git a/src/nomad_simulations/physical_property.py b/src/nomad_simulations/physical_property.py index d1a1cd03..0f908ac4 100644 --- a/src/nomad_simulations/physical_property.py +++ b/src/nomad_simulations/physical_property.py @@ -18,6 +18,7 @@ import numpy as np from typing import Any, Optional +from functools import wraps from nomad import utils from nomad.datamodel.data import ArchiveSection @@ -42,6 +43,43 @@ logger = utils.get_logger(__name__) +def validate_quantity_wrt_value(name: str = ''): + """ + Decorator to validate the existence of a quantity and its shape with respect to the `PhysicalProperty.value` + before calling a method. An example can be found in the module `properties/band_structure.py` for the method + `ElectronicEigenvalues.order_eigenvalues()`. + + Args: + name (str, optional): The name of the `quantity` to validate. Defaults to ''. + """ + + def decorator(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + # Checks if `quantity` is defined + quantity = getattr(self, name, None) + if quantity is None or len(quantity) == 0: + logger.warning(f'The quantity `{name}` is not defined.') + return False + + # Checks if `value` exists and has the same shape as `quantity` + value = getattr(self, 'value', None) + if value is None: + logger.warning(f'The quantity `value` is not defined.') + return False + if value is not None and value.shape != quantity.shape: + logger.warning( + f'The shape of the quantity `{name}` does not match the shape of the `value`.' + ) + return False + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + class PhysicalProperty(ArchiveSection): """ A base section used to define the physical properties obtained in a simulation, experiment, or in a post-processing @@ -221,11 +259,20 @@ def __init__( self, m_def: Section = None, m_context: Context = None, **kwargs ) -> None: super().__init__(m_def, m_context, **kwargs) + + # Checking if IRI is defined if self.iri is None: logger.warning( 'The used property is not defined in the FAIRmat taxonomy (https://fairmat-nfdi.github.io/fairmat-taxonomy/). You can contribute there if you want to extend the list of available materials properties.' ) + # Checking if the quantities `n_` are defined, as this are used to calculate `rank` + for quantity, _ in self.m_def.all_quantities.items(): + if quantity.startswith('n_') and getattr(self, quantity) is None: + raise ValueError( + f'`{quantity}` is not defined during initialization of the class.' + ) + 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': diff --git a/src/nomad_simulations/properties/__init__.py b/src/nomad_simulations/properties/__init__.py index 889196cd..3c6194ed 100644 --- a/src/nomad_simulations/properties/__init__.py +++ b/src/nomad_simulations/properties/__init__.py @@ -27,3 +27,5 @@ ) from .hopping_matrix import HoppingMatrix, CrystalFieldSplitting from .permittivity import Permittivity +from .fermi_surface import FermiSurface +from .band_structure import ElectronicEigenvalues, ElectronicBandStructure diff --git a/src/nomad_simulations/properties/band_gap.py b/src/nomad_simulations/properties/band_gap.py index 2949a7e6..0656197a 100644 --- a/src/nomad_simulations/properties/band_gap.py +++ b/src/nomad_simulations/properties/band_gap.py @@ -83,9 +83,9 @@ def __init__( self.name = self.m_def.name self.rank = [] - def _check_negative_values(self, logger: BoundLogger) -> Optional[pint.Quantity]: + def validate_values(self, logger: BoundLogger) -> Optional[pint.Quantity]: """ - Checks if the electronic band gaps is negative and sets them to None if they are. + Validate the electronic band gap `value` by checking if they are negative and sets them to None if they are. Args: logger (BoundLogger): The logger to log messages. @@ -143,7 +143,7 @@ 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) + self.value = self.validate_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.') diff --git a/src/nomad_simulations/properties/band_structure.py b/src/nomad_simulations/properties/band_structure.py new file mode 100644 index 00000000..d911f8a0 --- /dev/null +++ b/src/nomad_simulations/properties/band_structure.py @@ -0,0 +1,327 @@ +# +# 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 +from typing import Optional, Tuple, Union +import pint + +from nomad.metainfo import Quantity, Section, Context, SubSection + +from nomad_simulations.numerical_settings import KSpace +from nomad_simulations.physical_property import ( + PhysicalProperty, + validate_quantity_wrt_value, +) +from nomad_simulations.properties import ElectronicBandGap, FermiSurface +from nomad_simulations.utils import get_sibling_section + + +class BaseElectronicEigenvalues(PhysicalProperty): + """ + A base section used to define basic quantities for the `ElectronicEigenvalues` and `ElectronicBandStructure` properties. + """ + + iri = '' + + n_bands = Quantity( + type=np.int32, + description=""" + Number of bands / eigenvalues. + """, + ) + + value = Quantity( + type=np.float64, + unit='joule', + description=""" + Value of the electronic eigenvalues. + """, + ) + + def __init__( + self, m_def: Section = None, m_context: Context = None, **kwargs + ) -> None: + super().__init__(m_def, m_context, **kwargs) + # ! `n_bands` need to be set up during initialization of the class + self.rank = [kwargs.get('n_bands')] + + def normalize(self, archive, logger) -> None: + super().normalize(archive, logger) + + +class ElectronicEigenvalues(BaseElectronicEigenvalues): + """ """ + + iri = 'http://fairmat-nfdi.eu/taxonomy/ElectronicEigenvalues' + + spin_channel = Quantity( + type=np.int32, + description=""" + Spin channel of the corresponding electronic eigenvalues. It can take values of 0 or 1. + """, + ) + + occupation = Quantity( + type=np.float64, + shape=['*', 'n_bands'], + description=""" + Occupation of the electronic eigenvalues. This is a number depending whether the `spin_channel` has been set or not. + If `spin_channel` is set, then this number is between 0 and 2, where 0 means that the state is unoccupied and 2 means + that the state is fully occupied; if `spin_channel` is not set, then this number is between 0 and 1. The shape of + this quantity is defined as `[K.n_points, K.dimensionality, n_bands]`, where `K` is a `variable` which can + be `KMesh` or `KLinePath`, depending whether the simulation mapped the whole Brillouin zone or just a specific + path. + """, + ) + + highest_occupied = Quantity( + type=np.float64, + unit='joule', + description=""" + Highest occupied electronic eigenvalue. Together with `lowest_unoccupied`, it defines the + electronic band gap. + """, + ) + + lowest_unoccupied = Quantity( + type=np.float64, + unit='joule', + description=""" + Lowest unoccupied electronic eigenvalue. Together with `highest_occupied`, it defines the + electronic band gap. + """, + ) + + # ? Should we add functionalities to handle min/max of the `value` in some specific cases, .e.g, bands around the Fermi level, + # ? core bands separated by gaps, and equivalently, higher-energy valence bands separated by gaps? + + value_contributions = SubSection( + sub_section=BaseElectronicEigenvalues.m_def, + repeats=True, + description=""" + Contributions to the electronic eigenvalues. Example, in the case of a DFT+GW calculation, the GW eigenvalues + are stored under `value`, and each contribution is identified by `label`: + - `'KS'`: Kohn-Sham contribution. This is also stored in the DFT entry under `ElectronicEigenvalues.value`. + - `'KSxc'`: Diagonal matrix elements of the expectation value of the Kohn-Sahm exchange-correlation potential. + - `'SigX'`: Diagonal matrix elements of the exchange self-energy. This is also stored in the GW entry under `ElectronicSelfEnergy.value`. + - `'SigC'`: Diagonal matrix elements of the correlation self-energy. This is also stored in the GW entry under `ElectronicSelfEnergy.value`. + - `'Zk'`: Quasiparticle renormalization factors contribution. This is also stored in the GW entry under `QuasiparticleWeights.value`. + """, + ) + + reciprocal_cell = Quantity( + type=KSpace.reciprocal_lattice_vectors, + description=""" + Reference to the reciprocal lattice vectors stored under `KSpace`. + """, + ) + + 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 + + @validate_quantity_wrt_value(name='occupation') + def order_eigenvalues(self) -> Union[bool, Tuple[pint.Quantity, np.ndarray]]: + """ + Order the eigenvalues based on the `value` and `occupation`. The return `value` and + `occupation` are flattened. + + Returns: + (Union[bool, Tuple[pint.Quantity, np.ndarray]]): The flattened and sorted `value` and `occupation`. If validation + fails, then it returns `False`. + """ + total_shape = np.prod(self.value.shape) + + # Order the indices in the flattened list of `value` + flattened_value = self.value.reshape(total_shape) + flattened_occupation = self.occupation.reshape(total_shape) + sorted_indices = np.argsort(flattened_value, axis=0) + + sorted_value = ( + np.take_along_axis(flattened_value.magnitude, sorted_indices, axis=0) + * flattened_value.u + ) + sorted_occupation = np.take_along_axis( + flattened_occupation, sorted_indices, axis=0 + ) + self.m_cache['sorted_eigenvalues'] = True + return sorted_value, sorted_occupation + + def resolve_homo_lumo_eigenvalues( + self, + ) -> Tuple[Optional[pint.Quantity], Optional[pint.Quantity]]: + """ + Resolve the `highest_occupied` and `lowest_unoccupied` eigenvalues by performing a binary search on the + flattened and sorted `value` and `occupation`. If these quantities already exist, overwrite them or return + them if it is not possible to resolve from `value` and `occupation`. + + Returns: + (Tuple[Optional[pint.Quantity], Optional[pint.Quantity]]): The `highest_occupied` and + `lowest_unoccupied` eigenvalues. + """ + # Sorting `value` and `occupation` + if not self.order_eigenvalues(): # validation fails + if self.highest_occupied is not None and self.lowest_unoccupied is not None: + return self.highest_occupied, self.lowest_unoccupied + return None, None + sorted_value, sorted_occupation = self.order_eigenvalues() + sorted_value_unit = sorted_value.u + sorted_value = sorted_value.magnitude + + # Binary search ot find the transition point between `occupation = 2` and `occupation = 0` + tolerance = 1e-3 # TODO add tolerance from config fields + homo = self.highest_occupied + lumo = self.lowest_unoccupied + mid = np.searchsorted(sorted_occupation <= tolerance, True) - 1 + if mid >= 0 and mid < len(sorted_occupation) - 1: + if sorted_occupation[mid] > 0 and ( + sorted_occupation[mid + 1] >= -tolerance + and sorted_occupation[mid + 1] <= tolerance + ): + homo = sorted_value[mid] * sorted_value_unit + lumo = sorted_value[mid + 1] * sorted_value_unit + + return homo, lumo + + def extract_band_gap(self) -> Optional[ElectronicBandGap]: + """ + Extract the electronic band gap from the `highest_occupied` and `lowest_unoccupied` eigenvalues. + If the difference of `highest_occupied` and `lowest_unoccupied` is negative, the band gap `value` is set to 0.0. + + Returns: + (Optional[ElectronicBandGap]): The extracted electronic band gap section to be stored in `Outputs`. + """ + band_gap = None + homo, lumo = self.resolve_homo_lumo_eigenvalues() + if homo and lumo: + band_gap = ElectronicBandGap(is_derived=True, physical_property_ref=self) + + if (lumo - homo).magnitude < 0: + band_gap.value = 0.0 + else: + band_gap.value = lumo - homo + return band_gap + + # TODO fix this method once `FermiSurface` property is implemented + def extract_fermi_surface(self, logger: BoundLogger) -> Optional[FermiSurface]: + """ + Extract the Fermi surface for metal systems and using the `FermiLevel.value`. + + Args: + logger (BoundLogger): The logger to log messages. + + Returns: + (Optional[FermiSurface]): The extracted Fermi surface section to be stored in `Outputs`. + """ + # Check if the system has a finite band gap + homo, lumo = self.resolve_homo_lumo_eigenvalues() + if (homo and lumo) and (lumo - homo).magnitude > 0: + return None + + # Get the `fermi_level.value` + fermi_level = get_sibling_section( + section=self, sibling_section_name='fermi_level', logger=logger + ) + if fermi_level is None: + logger.warning( + 'Could not extract the `FermiSurface`, because `FermiLevel` is not stored.' + ) + return None + fermi_level_value = fermi_level.value.magnitude + + # Extract values close to the `fermi_level.value` + tolerance = 1e-8 # TODO change this for a config field + fermi_indices = np.logical_and( + self.value.magnitude >= (fermi_level_value - tolerance), + self.value.magnitude <= (fermi_level_value + tolerance), + ) + fermi_values = self.value[fermi_indices] + + # Store `FermiSurface` values + # ! This is wrong (!) the `value` should be the `KMesh.points`, not the `ElectronicEigenvalues.value` + fermi_surface = FermiSurface( + n_bands=self.n_bands, + is_derived=True, + physical_property_ref=self, + ) + fermi_surface.variables = self.variables + fermi_surface.value = fermi_values + return fermi_surface + + def resolve_reciprocal_cell(self) -> Optional[pint.Quantity]: + """ + Resolve the reciprocal cell from the `KSpace` numerical settings section. + + Returns: + Optional[pint.Quantity]: _description_ + """ + numerical_settings = self.m_xpath( + 'm_parent.m_parent.model_method[-1].numerical_settings', dict=False + ) + if numerical_settings is None: + return None + k_space = None + for setting in numerical_settings: + if isinstance(setting, KSpace): + k_space = setting + break + if k_space is None: + return None + return k_space + + def normalize(self, archive, logger) -> None: + super().normalize(archive, logger) + + # Resolve `highest_occupied` and `lowest_unoccupied` eigenvalues + self.highest_occupied, self.lowest_unoccupied = ( + self.resolve_homo_lumo_eigenvalues() + ) + + # `ElectronicBandGap` extraction + band_gap = self.extract_band_gap() + if band_gap is not None: + self.m_parent.electronic_band_gaps.append(band_gap) + + # TODO uncomment once `FermiSurface` property is implemented + # `FermiSurface` extraction + # fermi_surface = self.extract_fermi_surface(logger) + # if fermi_surface is not None: + # self.m_parent.fermi_surfaces.append(fermi_surface) + + # Resolve `reciprocal_cell` from the `KSpace` numerical settings section + self.reciprocal_cell = self.resolve_reciprocal_cell() + + +class ElectronicBandStructure(ElectronicEigenvalues): + """ + Accessible energies by the charges (electrons and holes) in the reciprocal space. + """ + + iri = 'http://fairmat-nfdi.eu/taxonomy/ElectronicBandStructure' + + 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 + + def normalize(self, archive, logger) -> None: + super().normalize(archive, logger) diff --git a/src/nomad_simulations/properties/fermi_surface.py b/src/nomad_simulations/properties/fermi_surface.py new file mode 100644 index 00000000..b8ce7972 --- /dev/null +++ b/src/nomad_simulations/properties/fermi_surface.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 numpy as np + +from nomad.metainfo import Quantity, Section, Context + +from nomad_simulations.physical_property import PhysicalProperty + + +# TODO This class is not implemented yet. @JosePizarro3 will work in another PR to implement it. +class FermiSurface(PhysicalProperty): + """ + Energy boundary in reciprocal space that separates the filled and empty electronic states in a metal. + It is related with the crossing points in reciprocal space by the chemical potential or, equivalently at + zero temperature, the Fermi level. + """ + + iri = 'http://fairmat-nfdi.eu/taxonomy/FermiSurface' + + n_bands = Quantity( + type=np.int32, + description=""" + Number of bands / eigenvalues. + """, + ) + + def __init__( + self, m_def: Section = None, m_context: Context = None, **kwargs + ) -> None: + super().__init__(m_def, m_context, **kwargs) + # ! `n_bands` need to be set up during initialization of the class + self.rank = [kwargs.get('n_bands')] + self.name = self.m_def.name + + def normalize(self, archive, logger) -> None: + super().normalize(archive, logger) diff --git a/src/nomad_simulations/properties/hopping_matrix.py b/src/nomad_simulations/properties/hopping_matrix.py index 8f163776..2ea16313 100644 --- a/src/nomad_simulations/properties/hopping_matrix.py +++ b/src/nomad_simulations/properties/hopping_matrix.py @@ -60,9 +60,7 @@ def __init__( ) -> None: super().__init__(m_def, m_context, **kwargs) # ! n_orbitals need to be set up during initialization of the class - self.rank = ( - [] if self.n_orbitals is None else [self.n_orbitals, self.n_orbitals] - ) + self.rank = [kwargs.get('n_orbitals'), kwargs.get('n_orbitals')] self.name = self.m_def.name # TODO add normalization to extract DOS, band structure, etc, properties from `HoppingMatrix` @@ -98,8 +96,8 @@ def __init__( self, m_def: Section = None, m_context: Context = None, **kwargs ) -> None: super().__init__(m_def, m_context, **kwargs) - # ! n_orbitals need to be set up during initialization of the class - self.rank = [] if self.n_orbitals is None else [self.n_orbitals] + # ! `n_orbitals` need to be set up during initialization of the class + self.rank = [kwargs.get('n_orbitals')] self.name = self.m_def.name def normalize(self, archive, logger) -> None: diff --git a/src/nomad_simulations/properties/spectral_profile.py b/src/nomad_simulations/properties/spectral_profile.py index 03bd60ac..ddfcfaa6 100644 --- a/src/nomad_simulations/properties/spectral_profile.py +++ b/src/nomad_simulations/properties/spectral_profile.py @@ -204,9 +204,6 @@ def resolve_energies_origin( Returns: (Optional[pint.Quantity]): The resolved origin of reference for the energies. """ - # ! We need schema for `ElectronicEigenvalues` to store `highest_occupied` and `lowest_occupied` - # TODO improve and check this normalization after implementing `ElectronicEigenvalues` - # Check if the variables contain more than one variable (different than Energy) # ? Is this correct or should be use the index of energies to extract the proper shape element in `self.value` being used for `dos_values`? if len(self.variables) > 1: @@ -215,7 +212,7 @@ def resolve_energies_origin( ) return None - # Extract the `ElectronicEigenvalues` section to get the `highest_occupied` and `lowest_occupied` energies + # Extract the `ElectronicEigenvalues` section to get the `highest_occupied` and `lowest_unoccupied` energies # TODO implement once `ElectronicEigenvalues` is in the schema eigenvalues = get_sibling_section( section=self, sibling_section_name='electronic_eigenvalues', logger=logger @@ -223,14 +220,14 @@ def resolve_energies_origin( highest_occupied_energy = ( eigenvalues.highest_occupied if eigenvalues is not None else None ) - lowest_occupied_energy = ( - eigenvalues.lowest_occupied if eigenvalues is not None else None + lowest_unoccupied_energy = ( + eigenvalues.lowest_unoccupied if eigenvalues is not None else None ) - # and set defaults for `highest_occupied_energy` and `lowest_occupied_energy` in `m_cache` + # and set defaults for `highest_occupied_energy` and `lowest_unoccupied_energy` in `m_cache` if highest_occupied_energy is not None: self.m_cache['highest_occupied_energy'] = highest_occupied_energy - if lowest_occupied_energy is not None: - self.m_cache['lowest_occupied_energy'] = lowest_occupied_energy + if lowest_unoccupied_energy is not None: + self.m_cache['lowest_unoccupied_energy'] = lowest_unoccupied_energy # Set thresholds for the energies and values energy_threshold = config.normalize.band_structure_energy_tolerance @@ -281,7 +278,7 @@ def resolve_energies_origin( # search needs to be performed. if idx_ascend != fermi_idx and idx_descend != fermi_idx: self.m_cache['highest_occupied_energy'] = fermi_energy_closest - self.m_cache['lowest_occupied_energy'] = fermi_energy_closest + self.m_cache['lowest_unoccupied_energy'] = fermi_energy_closest single_peak_fermi = True if not single_peak_fermi: @@ -363,16 +360,16 @@ def resolve_normalization_factor(self, logger: BoundLogger) -> Optional[float]: def extract_band_gap(self) -> Optional[ElectronicBandGap]: """ - Extract the electronic band gap from the `highest_occupied_energy` and `lowest_occupied_energy` stored + Extract the electronic band gap from the `highest_occupied_energy` and `lowest_unoccupied_energy` stored in `m_cache` from `resolve_energies_origin()`. If the difference of `highest_occupied_energy` and - `lowest_occupied_energy` is negative, the band gap `value` is set to 0.0. + `lowest_unoccupied_energy` is negative, the band gap `value` is set to 0.0. Returns: (Optional[ElectronicBandGap]): The extracted electronic band gap section to be stored in `Outputs`. """ band_gap = None homo = self.m_cache.get('highest_occupied_energy') - lumo = self.m_cache.get('lowest_occupied_energy') + lumo = self.m_cache.get('lowest_unoccupied_energy') if homo and lumo: band_gap = ElectronicBandGap() band_gap.is_derived = True diff --git a/src/nomad_simulations/variables.py b/src/nomad_simulations/variables.py index d3fd1859..89f254f4 100644 --- a/src/nomad_simulations/variables.py +++ b/src/nomad_simulations/variables.py @@ -58,7 +58,7 @@ class Variables(ArchiveSection): """, ) - # points_error = Quantity() + # ? Do we need to add `points_error`? def get_n_points(self, logger: BoundLogger) -> Optional[int]: """ @@ -182,7 +182,7 @@ def normalize(self, archive, logger) -> None: class KMesh(Variables): """ K-point mesh over which the physical property is calculated. This is used to define `ElectronicEigenvalues(PhysicalProperty)` and - other k-space properties. The `points` are obtained from a refernece to the `NumericalSettings` section, `KMesh(NumericalSettings)`. + other k-space properties. The `points` are obtained from a reference to the `NumericalSettings` section, `KMesh(NumericalSettings)`. """ points = Quantity( diff --git a/tests/conftest.py b/tests/conftest.py index 2f62bb31..f3929ebe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,7 @@ import os import numpy as np import pytest -from typing import List, Optional, Dict +from typing import List, Optional from nomad.units import ureg from nomad.datamodel import EntryArchive @@ -37,11 +37,12 @@ KLinePath as KLinePathSettings, ) from nomad_simulations.outputs import Outputs, SCFOutputs -from nomad_simulations.variables import Energy2 as Energy +from nomad_simulations.variables import Energy2 as Energy, KLinePath from nomad_simulations.properties import ( ElectronicBandGap, DOSProfile, ElectronicDensityOfStates, + ElectronicEigenvalues, ) if os.getenv('_PYTEST_RAISE', '0') != '0': @@ -258,6 +259,7 @@ def generate_k_space_simulation( [0, 0.5, 0], [0, 0, 0], ], + klinepath_points: Optional[List[float]] = None, grid=[6, 6, 6], ) -> Simulation: model_system = generate_model_system( @@ -283,6 +285,8 @@ def generate_k_space_simulation( high_symmetry_path_names=high_symmetry_path_names, high_symmetry_path_values=high_symmetry_path_values, ) + if klinepath_points is not None: + k_line_path.points = klinepath_points k_space.k_line_path = k_line_path # appending `KSpace` to `ModelMethod.numerical_settings` model_method = ModelMethod() @@ -290,6 +294,75 @@ def generate_k_space_simulation( return generate_simulation(model_method=model_method, model_system=model_system) +def generate_electronic_eigenvalues( + reciprocal_lattice_vectors: Optional[List[List[float]]] = [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ], + value: Optional[list] = [ + [3, -2], + [3, 1], + [4, -2], + [5, -1], + [4, 0], + [2, 0], + [2, 1], + [4, -3], + ], + occupation: Optional[list] = [ + [0, 2], + [0, 1], + [0, 2], + [0, 2], + [0, 1.5], + [0, 1.5], + [0, 1], + [0, 2], + ], + highest_occupied: Optional[float] = None, + lowest_unoccupied: Optional[float] = None, +) -> ElectronicEigenvalues: + """ + Generate an `ElectronicEigenvalues` section with the given parameters. + """ + outputs = Outputs() + k_space = KSpace( + k_line_path=KLinePathSettings( + points=[ + [0, 0, 0], + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [1, 1, 0], + [1, 0, 1], + [0, 1, 1], + [1, 1, 1], + ] + ) + ) + model_method = ModelMethod(numerical_settings=[k_space]) + if reciprocal_lattice_vectors is not None and len(reciprocal_lattice_vectors) > 0: + k_space.reciprocal_lattice_vectors = reciprocal_lattice_vectors + simulation = generate_simulation( + model_system=generate_model_system(), + model_method=model_method, + outputs=outputs, + ) + electronic_eigenvalues = ElectronicEigenvalues(n_bands=2) + outputs.electronic_eigenvalues.append(electronic_eigenvalues) + electronic_eigenvalues.variables = [ + KLinePath(points=model_method.numerical_settings[0].k_line_path) + ] + if value is not None: + electronic_eigenvalues.value = value + if occupation is not None: + electronic_eigenvalues.occupation = occupation + electronic_eigenvalues.highest_occupied = highest_occupied + electronic_eigenvalues.lowest_unoccupied = lowest_unoccupied + return electronic_eigenvalues + + @pytest.fixture(scope='session') def model_system() -> ModelSystem: return generate_model_system() diff --git a/tests/test_atoms_state.py b/tests/test_atoms_state.py index bcb6b7dd..4482d1ea 100644 --- a/tests/test_atoms_state.py +++ b/tests/test_atoms_state.py @@ -64,18 +64,18 @@ def add_state( ('ms_quantum_number', [0, 10, -0.5, 0.5], [False, False, True, True]), ], ) - def test_check_quantum_numbers( + def test_validate_quantum_numbers( self, number_label: str, values: List[int], results: List[bool] ): """ - Test the quantum number check for the `OrbitalsState` section. + Test the `validate_quantum_numbers` method. """ orbital_state = OrbitalsState(n_quantum_number=2) for val, res in zip(values, results): if number_label == 'ml_quantum_number': orbital_state.l_quantum_number = 2 setattr(orbital_state, number_label, val) - assert orbital_state._check_quantum_numbers(logger) == res + assert orbital_state.validate_quantum_numbers(logger) == res @pytest.mark.parametrize( 'quantum_name, value, expected_result', diff --git a/tests/test_band_gap.py b/tests/test_band_gap.py index 1c2b3e63..062370c3 100644 --- a/tests/test_band_gap.py +++ b/tests/test_band_gap.py @@ -56,11 +56,9 @@ def test_default_quantities(self): ([1.0, 2.0, -1.0], None), ], ) - def test_check_negative_values( - self, value: Union[List[float], float], result: float - ): + def test_validate_values(self, value: Union[List[float], float], result: float): """ - Test the `_check_negative_values` method. + Test the `validate_values` method. """ if isinstance(value, list): electronic_band_gap = ElectronicBandGap( @@ -69,11 +67,11 @@ def test_check_negative_values( 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) + validated_value = electronic_band_gap.validate_values(logger) + if validated_value is not None: + assert np.isclose(validated_value.magnitude, result) else: - assert checked_value == result + assert validated_value == result @pytest.mark.parametrize( 'momentum_transfer, type, result', diff --git a/tests/test_band_structure.py b/tests/test_band_structure.py new file mode 100644 index 00000000..c7b920ac --- /dev/null +++ b/tests/test_band_structure.py @@ -0,0 +1,431 @@ +# +# 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, Tuple, Union, List +import pint + +from nomad.datamodel import EntryArchive + +from nomad_simulations.model_method import ModelMethod +from nomad_simulations.numerical_settings import KSpace, BasisSet +from nomad_simulations.outputs import Outputs +from nomad_simulations.properties import ElectronicEigenvalues + +from . import logger +from .conftest import generate_electronic_eigenvalues, generate_simulation + + +class TestElectronicEigenvalues: + """ + Test the `ElectronicEigenvalues` class defined in `properties/band_structure.py`. + """ + + # ! Include this initial `test_default_quantities` method when testing your PhysicalProperty classes + @pytest.mark.parametrize( + 'n_bands, rank', + [ + (None, None), + (10, [10]), + ], + ) + def test_default_quantities(self, n_bands: Optional[int], rank: Optional[list]): + """ + Test the default quantities assigned when creating an instance of the `HoppingMatrix` class. + """ + if n_bands is None: + with pytest.raises(ValueError) as exc_info: + electronic_eigenvalues = ElectronicEigenvalues(n_bands=n_bands) + assert ( + str(exc_info.value) + == f'`n_bands` is not defined during initialization of the class.' + ) + else: + electronic_eigenvalues = ElectronicEigenvalues(n_bands=n_bands) + assert ( + electronic_eigenvalues.iri + == 'http://fairmat-nfdi.eu/taxonomy/ElectronicEigenvalues' + ) + assert electronic_eigenvalues.name == 'ElectronicEigenvalues' + assert electronic_eigenvalues.rank == rank + + # @pytest.mark.parametrize( + # 'occupation, result', + # [ + # (None, False), + # ([], False), + # ([[2, 2], [0, 0]], False), # `value` and `occupation` must have same shape + # ( + # [[0, 2], [0, 1], [0, 2], [0, 2], [0, 1.5], [0, 1.5], [0, 1], [0, 2]], + # True, + # ), + # ], + # ) + # def test_validate_occupation(self, occupation: Optional[list], result: bool): + # """ + # Test the `validate_occupation` method. + # """ + # electronic_eigenvalues = generate_electronic_eigenvalues( + # value=[[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + # occupation=occupation, + # ) + # assert electronic_eigenvalues.validate_occupation(logger) == result + + @pytest.mark.parametrize( + 'occupation, value, result_validation, result', + [ + ( + None, + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + False, + (None, None), + ), + ( + [], + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + False, + (None, None), + ), + ( + [[2, 2], [0, 0]], + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + False, + (None, None), + ), # `value` and `occupation` must have same shape + ( + [[0, 2], [0, 1], [0, 2], [0, 2], [0, 1.5], [0, 1.5], [0, 1], [0, 2]], + None, + False, + (None, None), + ), + ( + [[0, 2], [0, 1], [0, 2], [0, 2], [0, 1.5], [0, 1.5], [0, 1], [0, 2]], + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + True, + ( + [ + -3, + -2, + -2, + -1, + 0, + 0, + 1, + 1, + 2, + 2, + 3, + 3, + 4, + 4, + 4, + 5, + ], + [ + 2.0, + 2.0, + 2.0, + 2.0, + 1.5, + 1.5, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + ), + ), + ], + ) + def test_order_eigenvalues( + self, + occupation: Optional[list], + value: Optional[list], + result_validation: bool, + result: Tuple[list, list], + ): + """ + Test the `order_eigenvalues` method. + """ + electronic_eigenvalues = generate_electronic_eigenvalues( + value=value, + occupation=occupation, + ) + order_result = electronic_eigenvalues.order_eigenvalues() + if not order_result: + assert order_result == result_validation + else: + sorted_value, sorted_occupation = order_result + assert electronic_eigenvalues.m_cache['sorted_eigenvalues'] + assert (sorted_value.magnitude == result[0]).all() + assert (sorted_occupation == result[1]).all() + + @pytest.mark.parametrize( + 'occupation, value, highest_occupied, lowest_unoccupied, result', + [ + # Not possible to resolve `highest_occupied` and `lowest_unoccupied` + ( + None, + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + None, + None, + (None, None), + ), + ( + [], + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + None, + None, + (None, None), + ), + ( + [[2, 2], [0, 0]], + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + None, + None, + (None, None), + ), # `value` and `occupation` must have same shape + ( + [[0, 2], [0, 1], [0, 2], [0, 2], [0, 1.5], [0, 1.5], [0, 1], [0, 2]], + None, + None, + None, + (None, None), + ), + # `highest_occupied` and `lowest_unoccupied` are passed to the class + ( + None, + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + 1.0, + 2.0, + (1.0, 2.0), + ), + ( + [], + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + 1.0, + 2.0, + (1.0, 2.0), + ), + ( + [[2, 2], [0, 0]], + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + 1.0, + 2.0, + (1.0, 2.0), + ), + ( + [[0, 2], [0, 1], [0, 2], [0, 2], [0, 1.5], [0, 1.5], [0, 1], [0, 2]], + None, + 1.0, + 2.0, + (1.0, 2.0), + ), + # Resolving `highest_occupied` and `lowest_unoccupied` from `value` and `occupation` + ( + [[0, 2], [0, 1], [0, 2], [0, 2], [0, 1.5], [0, 1.5], [0, 1], [0, 2]], + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + None, + None, + (1.0, 2.0), + ), + # Overwritting stored `highest_occupied` and `lowest_unoccupied` from `value` and `occupation` + ( + [[0, 2], [0, 1], [0, 2], [0, 2], [0, 1.5], [0, 1.5], [0, 1], [0, 2]], + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + -3.0, + 4.0, + (1.0, 2.0), + ), + ], + ) + def test_homo_lumo_eigenvalues( + self, + occupation: Optional[list], + value: Optional[list], + highest_occupied: Optional[float], + lowest_unoccupied: Optional[float], + result: Tuple[Optional[float], Optional[float]], + ): + """ + Test the `resolve_homo_lumo_eigenvalues` method. + """ + electronic_eigenvalues = generate_electronic_eigenvalues( + value=value, + occupation=occupation, + highest_occupied=highest_occupied, + lowest_unoccupied=lowest_unoccupied, + ) + homo, lumo = electronic_eigenvalues.resolve_homo_lumo_eigenvalues() + if homo is not None and lumo is not None: + assert (homo.magnitude, lumo.magnitude) == result + else: + assert (homo, lumo) == result + + @pytest.mark.parametrize( + 'occupation, value, highest_occupied, lowest_unoccupied, band_gap_result', + [ + # Not possible to resolve `highest_occupied` and `lowest_unoccupied` + ( + None, + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + None, + None, + None, + ), + ( + [], + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + None, + None, + None, + ), + ( + [[2, 2], [0, 0]], + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + None, + None, + None, + ), # `value` and `occupation` must have same shape + ( + [[0, 2], [0, 1], [0, 2], [0, 2], [0, 1.5], [0, 1.5], [0, 1], [0, 2]], + None, + None, + None, + None, + ), + # `highest_occupied` and `lowest_unoccupied` are passed to the class + ( + None, + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + 1.0, + 2.0, + 1.0, + ), + ( + [], + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + 1.0, + 2.0, + 1.0, + ), + ( + [[2, 2], [0, 0]], + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + 1.0, + 2.0, + 1.0, + ), + ( + [[0, 2], [0, 1], [0, 2], [0, 2], [0, 1.5], [0, 1.5], [0, 1], [0, 2]], + None, + 1.0, + 2.0, + 1.0, + ), + # If (lumo - homo) is negative, band_gap_result is 0 + ( + [[0, 2], [0, 1], [0, 2], [0, 2], [0, 1.5], [0, 1.5], [0, 1], [0, 2]], + None, + 3.0, + 2.0, + 0.0, + ), + # Resolving `highest_occupied` and `lowest_unoccupied` from `value` and `occupation` + ( + [[0, 2], [0, 1], [0, 2], [0, 2], [0, 1.5], [0, 1.5], [0, 1], [0, 2]], + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + None, + None, + 1.0, + ), + # Overwritting stored `highest_occupied` and `lowest_unoccupied` from `value` and `occupation` + ( + [[0, 2], [0, 1], [0, 2], [0, 2], [0, 1.5], [0, 1.5], [0, 1], [0, 2]], + [[3, -2], [3, 1], [4, -2], [5, -1], [4, 0], [2, 0], [2, 1], [4, -3]], + -3.0, + 4.0, + 1.0, + ), + ], + ) + def test_extract_band_gap( + self, + occupation: Optional[list], + value: Optional[list], + highest_occupied: Optional[float], + lowest_unoccupied: Optional[float], + band_gap_result: Optional[float], + ): + """ + Test the `extract_band_gap` method. + """ + electronic_eigenvalues = generate_electronic_eigenvalues( + value=value, + occupation=occupation, + highest_occupied=highest_occupied, + lowest_unoccupied=lowest_unoccupied, + ) + band_gap = electronic_eigenvalues.extract_band_gap() + if band_gap is not None: + assert np.isclose(band_gap.value.magnitude, band_gap_result) + else: + assert band_gap == band_gap_result + + def test_extract_fermi_surface(self): + """ + Test the `extract_band_gap` method. + """ + # ! add test when `FermiSurface` is implemented + pass + + @pytest.mark.parametrize( + 'reciprocal_lattice_vectors, result', + [ + (None, None), + ([], None), + ([[1, 0, 0], [0, 1, 0], [0, 0, 1]], [[1, 0, 0], [0, 1, 0], [0, 0, 1]]), + ], + ) + def test_resolve_reciprocal_cell( + self, + reciprocal_lattice_vectors: Optional[List[List[float]]], + result: Optional[List[List[float]]], + ): + """ + Test the `resolve_reciprocal_cell` method. This is done via the `normalize` function because `reciprocal_cell` is a + `QuantityReference`, hence we need to assign it. + """ + electronic_eigenvalues = generate_electronic_eigenvalues( + reciprocal_lattice_vectors=reciprocal_lattice_vectors + ) + # `normalize()` instead of `resolve_reciprocal_cell()` in order for refs to work + # reciprocal_cell = electronic_eigenvalues.resolve_reciprocal_cell() + electronic_eigenvalues.normalize(EntryArchive(), logger) + reciprocal_cell = electronic_eigenvalues.reciprocal_cell + if reciprocal_cell is not None: + assert np.allclose(reciprocal_cell.magnitude, result) + else: + assert reciprocal_cell == result diff --git a/tests/test_fermi_surface.py b/tests/test_fermi_surface.py new file mode 100644 index 00000000..59e1a41f --- /dev/null +++ b/tests/test_fermi_surface.py @@ -0,0 +1,56 @@ +# +# 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 + +from nomad_simulations.properties import FermiSurface + +from . import logger + + +class TestFermiSurface: + """ + Test the `FermiSurface` class defined in `properties/band_structure.py`. + """ + + # ! Include this initial `test_default_quantities` method when testing your PhysicalProperty classes + @pytest.mark.parametrize( + 'n_bands, rank', + [ + (None, None), + (10, [10]), + ], + ) + def test_default_quantities(self, n_bands: Optional[int], rank: Optional[list]): + """ + Test the default quantities assigned when creating an instance of the `HoppingMatrix` class. + """ + if n_bands is None: + with pytest.raises(ValueError) as exc_info: + fermi_surface = FermiSurface(n_bands=n_bands) + assert ( + str(exc_info.value) + == f'`n_bands` is not defined during initialization of the class.' + ) + else: + fermi_surface = FermiSurface(n_bands=n_bands) + assert fermi_surface.iri == 'http://fairmat-nfdi.eu/taxonomy/FermiSurface' + assert fermi_surface.name == 'FermiSurface' + assert fermi_surface.rank == rank diff --git a/tests/test_hopping_matrix.py b/tests/test_hopping_matrix.py index d5d6d8af..f15f4b05 100644 --- a/tests/test_hopping_matrix.py +++ b/tests/test_hopping_matrix.py @@ -17,10 +17,12 @@ # import pytest -import numpy as np +from typing import Optional from nomad_simulations.properties import HoppingMatrix, CrystalFieldSplitting +from . import logger + class TestHoppingMatrix: """ @@ -31,18 +33,26 @@ class TestHoppingMatrix: @pytest.mark.parametrize( 'n_orbitals, rank', [ - (None, []), + (None, None), (3, [3, 3]), ], ) - def test_default_quantities(self, n_orbitals: int, rank: list): + def test_default_quantities(self, n_orbitals: Optional[int], rank: Optional[list]): """ Test the default quantities assigned when creating an instance of the `HoppingMatrix` class. """ - hopping_matrix = HoppingMatrix(n_orbitals=n_orbitals) - assert hopping_matrix.iri == 'http://fairmat-nfdi.eu/taxonomy/HoppingMatrix' - assert hopping_matrix.name == 'HoppingMatrix' - assert hopping_matrix.rank == rank + if n_orbitals is None: + with pytest.raises(ValueError) as exc_info: + hopping_matrix = HoppingMatrix(n_orbitals=n_orbitals) + assert ( + str(exc_info.value) + == f'`n_orbitals` is not defined during initialization of the class.' + ) + else: + hopping_matrix = HoppingMatrix(n_orbitals=n_orbitals) + assert hopping_matrix.iri == 'http://fairmat-nfdi.eu/taxonomy/HoppingMatrix' + assert hopping_matrix.name == 'HoppingMatrix' + assert hopping_matrix.rank == rank class TestCrystalFieldSplitting: @@ -54,17 +64,26 @@ class TestCrystalFieldSplitting: @pytest.mark.parametrize( 'n_orbitals, rank', [ - (None, []), + (None, None), (3, [3]), ], ) - def test_default_quantities(self, n_orbitals: int, rank: list): + def test_default_quantities(self, n_orbitals: Optional[int], rank: Optional[list]): """ Test the default quantities assigned when creating an instance of the `CrystalFieldSplitting` class. """ - crystal_field = CrystalFieldSplitting(n_orbitals=n_orbitals) - assert ( - crystal_field.iri == 'http://fairmat-nfdi.eu/taxonomy/CrystalFieldSplitting' - ) - assert crystal_field.name == 'CrystalFieldSplitting' - assert crystal_field.rank == rank + if n_orbitals is None: + with pytest.raises(ValueError) as exc_info: + crystal_field = CrystalFieldSplitting(n_orbitals=n_orbitals) + assert ( + str(exc_info.value) + == f'`n_orbitals` is not defined during initialization of the class.' + ) + else: + crystal_field = CrystalFieldSplitting(n_orbitals=n_orbitals) + assert ( + crystal_field.iri + == 'http://fairmat-nfdi.eu/taxonomy/CrystalFieldSplitting' + ) + assert crystal_field.name == 'CrystalFieldSplitting' + assert crystal_field.rank == rank diff --git a/tests/test_numerical_settings.py b/tests/test_numerical_settings.py index 11d94ba4..9ef5753a 100644 --- a/tests/test_numerical_settings.py +++ b/tests/test_numerical_settings.py @@ -90,7 +90,7 @@ class TestKSpaceFunctionalities: ([[1, 0, 0], [0, 1, 0], [0, 0, 1]], True, [6, 6, 6], True), ], ) - def test_check_reciprocal_lattice_vectors( + def test_validate_reciprocal_lattice_vectors( self, reciprocal_lattice_vectors: Optional[List[List[float]]], check_grid: bool, @@ -98,9 +98,9 @@ def test_check_reciprocal_lattice_vectors( result: bool, ): """ - Test the `_check_reciprocal_lattice_vectors` private method. + Test the `validate_reciprocal_lattice_vectors` method. """ - check = KSpaceFunctionalities()._check_reciprocal_lattice_vectors( + check = KSpaceFunctionalities().validate_reciprocal_lattice_vectors( reciprocal_lattice_vectors=reciprocal_lattice_vectors, logger=logger, check_grid=check_grid, @@ -292,20 +292,20 @@ class TestKLinePath: (['Gamma', 'X', 'Y'], [[0, 0, 0], [0.5, 0, 0], [0, 0.5, 0]], True), ], ) - def test_check_high_symmetry_path( + def test_validate_high_symmetry_path( self, high_symmetry_path_names: List[str], high_symmetry_path_values: List[List[float]], result: bool, ): """ - Test the `_check_high_symmetry_path` private method. + Test the `validate_high_symmetry_path` method. """ k_line_path = generate_k_line_path( high_symmetry_path_names=high_symmetry_path_names, high_symmetry_path_values=high_symmetry_path_values, ) - assert k_line_path._check_high_symmetry_path(logger) == result + assert k_line_path.validate_high_symmetry_path(logger) == result @pytest.mark.parametrize( 'reciprocal_lattice_vectors, high_symmetry_path_names, result', diff --git a/tests/test_physical_properties.py b/tests/test_physical_properties.py index d66c924e..d17650fe 100644 --- a/tests/test_physical_properties.py +++ b/tests/test_physical_properties.py @@ -18,6 +18,7 @@ import numpy as np import pytest +from typing import Union, Optional from nomad.units import ureg from nomad.datamodel import EntryArchive @@ -26,7 +27,10 @@ from . import logger from nomad_simulations.variables import Variables -from nomad_simulations.physical_property import PhysicalProperty +from nomad_simulations.physical_property import ( + PhysicalProperty, + validate_quantity_wrt_value, +) class DummyPhysicalProperty(PhysicalProperty): @@ -158,3 +162,49 @@ def test_is_derived(self): assert derived_physical_property._is_derived() is True derived_physical_property.normalize(EntryArchive(), logger) assert derived_physical_property.is_derived is True + + +# testing `validate_quantity_wrt_value` decorator +class ValidatingClass: + def __init__(self, value=None, occupation=None): + self.value = value + self.occupation = occupation + + @validate_quantity_wrt_value('occupation') + def validate_occupation(self) -> Union[bool, np.ndarray]: + return self.occupation + + +@pytest.mark.parametrize( + 'value, occupation, result', + [ + (None, None, False), # Both value and occupation are None + (np.array([[1, 2], [3, 4]]), None, False), # occupation is None + (None, np.array([[0.5, 1], [0, 0.5]]), False), # value is None + (np.array([[1, 2], [3, 4]]), np.array([]), False), # occupation is empty + ( + np.array([[1, 2], [3, 4]]), + np.array([[0.5, 1]]), + False, + ), # Shapes do not match + ( + np.array([[1, 2], [3, 4]]), + np.array([[0.5, 1], [0, 0.5]]), + np.array([[0.5, 1], [0, 0.5]]), + ), # Valid case (return `occupation`) + ], +) +def test_validate_quantity_wrt_value( + value: Optional[np.ndarray], + occupation: Optional[np.ndarray], + result: Union[bool, np.ndarray], +): + """ + Test the `validate_quantity_wrt_value` decorator. + """ + obj = ValidatingClass(value=value, occupation=occupation) + validation = obj.validate_occupation() + if isinstance(validation, bool): + assert validation == result + else: + assert np.allclose(validation, result)