Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Redefine PhysicalProperty.value interface #146

Open
wants to merge 11 commits into
base: develop
Choose a base branch
from
177 changes: 86 additions & 91 deletions src/nomad_simulations/schema_packages/physical_property.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from functools import wraps
from typing import TYPE_CHECKING, Any, Optional
from typing import TYPE_CHECKING, Any
from types import NoneType

import numpy as np
from nomad import utils
from nomad.units import ureg
from nomad.datamodel.data import ArchiveSection
from nomad.datamodel.metainfo.annotations import ELNAnnotation
from nomad.datamodel.metainfo.basesections import Entity
Expand All @@ -14,7 +16,7 @@
SectionProxy,
SubSection,
)
from nomad.metainfo.metainfo import Dimension, DirectQuantity, _placeholder_quantity
from nomad.metainfo.metainfo import _placeholder_quantity

if TYPE_CHECKING:
from nomad.datamodel.datamodel import EntryArchive
Expand All @@ -29,7 +31,7 @@
logger = utils.get_logger(__name__)


def validate_quantity_wrt_value(name: str = ''):
def validate_quantity_wrt_value(name: str = ''): # ! tone down to `quantity_present`
"""
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
Expand Down Expand Up @@ -109,7 +111,7 @@ class PhysicalProperty(ArchiveSection):
or `'indirect'`.
""",
# ! add more examples in the description to improve the understanding of this quantity
)
) # ?

label = Quantity(
type=str,
Expand All @@ -118,26 +120,14 @@ class PhysicalProperty(ArchiveSection):
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: Any = None

# * `value` must be overwritten in the derived classes defining its type, unit, and description
value: Quantity = _placeholder_quantity
_base_value: Quantity = _placeholder_quantity

entity_ref = Quantity(
type=Entity,
Expand Down Expand Up @@ -175,7 +165,7 @@ class PhysicalProperty(ArchiveSection):
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.
""",
)
) # TODO: move to numerical settings

self_consistency_ref = Quantity(
type=SelfConsistency,
Expand All @@ -186,7 +176,7 @@ class PhysicalProperty(ArchiveSection):
)

@property
def variables_shape(self) -> Optional[list]:
def variables_shape(self) -> list[int]:
"""
Shape of the variables over which the physical property varies. This is extracted from
`Variables.n_points` and appended in a list.
Expand All @@ -197,10 +187,29 @@ def variables_shape(self) -> Optional[list]:
Returns:
(list): The shape of the variables over which the physical property varies.
"""
if self.variables is not None:
return [v.get_n_points(logger) for v in self.variables]
if self.variables:
return [
v.get_n_points(logger) for v in self.variables
] # ! TODO: support any variable shape, not just vectors
return []

@property
def rank(self) -> list[int]:
"""
Rank of the physical property. This quantity is related with the order of the tensor of `value`.

Example: a physical property which is a 3D vector will have `rank=[3]`.

Returns:
(list): The rank of the physical property.
"""
if base_value := self.m_def.all_quantities.get('_base_value'):
if isinstance(base_value.shape, list):
return base_value.shape
else:
return []
raise ValueError('The `_base_value` quantity is not defined.')

@property
def full_shape(self) -> list:
"""
Expand All @@ -220,80 +229,36 @@ def full_shape(self) -> list:
Returns:
(list): The full shape of the physical property.
"""
return self.variables_shape + self.rank

@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.

Returns:
(Quantity): The new `Quantity` object for setting the `value` quantity.
"""
value_quantity = self.m_def.all_quantities.get('value')
if value_quantity is None:
return None
return Quantity(
type=value_quantity.type,
unit=value_quantity.unit, # ? this can be moved to __setattr__
description=value_quantity.description,
)
if (full_rank := self.variables_shape + self.rank):
return full_rank
else:
return ['*']

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

# Checking if IRI is defined
if self.iri is None:
if not self.iri:
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':
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)
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_points` and the `shape` defined in `PhysicalProperty`.'
)
_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:
'The used property is not defined in the FAIRmat taxonomy (https://fairmat-nfdi.github.io/fairmat-taxonomy/).'
) # ?

def __setattr__(self, name: str, value: Any) -> None:
# redirect from `_base_value` to `value`
if name == '_base_value':
if not isinstance(value, str):
self.value = value
elif not value.startswith('m_'):
self.value = value
else:
super().__setattr__(name, value)

def _is_derived(self) -> bool: # ?
"""
Resolves if the physical property is derived or not.
Resolves whether the physical property is derived or not.

Returns:
(bool): The flag indicating whether the physical property is derived or not.
Expand All @@ -305,8 +270,38 @@ def _is_derived(self) -> bool:
def normalize(self, archive: 'EntryArchive', logger: 'BoundLogger') -> None:
super().normalize(archive, logger)

# Resolve if the physical property `is_derived` or not from another physical property.
self.is_derived = self._is_derived()
# self.is_derived = self._is_derived() # ?

try:
if self.value is not None:
value = self.value
elif self._base_value is not None:
value = self._base_value
else:
value = None
except AttributeError:
raise AttributeError(
'The `value` or `_base_value` is not defined at the _quantity_ level.'
)

self.m_def.quantities.append(
Quantity(
name='value',
shape=self.full_shape,
type=self.m_def.all_quantities['_base_value'].type,
unit=self.m_def.all_quantities['_base_value'].unit,
description=self.m_def.all_quantities['_base_value'].description,
)
)

if isinstance(value, NoneType):
self.value = None
elif isinstance(value, ureg.Quantity):
self.value = np.asarray(value.to(self.m_def.all_quantities['value'].unit).magnitude)
elif isinstance(value, np.ndarray) or isinstance(value, list):
self.value = value
else:
self.value = [value]


class PropertyContribution(PhysicalProperty):
Expand Down
76 changes: 11 additions & 65 deletions src/nomad_simulations/schema_packages/properties/band_gap.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class ElectronicBandGap(PhysicalProperty):

type = Quantity(
type=MEnum('direct', 'indirect'),
shape=['*'],
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'`
Expand All @@ -34,15 +35,13 @@ class ElectronicBandGap(PhysicalProperty):

momentum_transfer = Quantity(
type=np.float64,
shape=[2, 3],
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`.
`momentum_transfer = [[[0, 0, 0], [0.5, 0.5, 0]]]`.
""",
)

Expand All @@ -53,7 +52,7 @@ class ElectronicBandGap(PhysicalProperty):
""",
)

value = Quantity(
_base_value = Quantity(
type=np.float64,
unit='joule',
description="""
Expand All @@ -62,36 +61,7 @@ class ElectronicBandGap(PhysicalProperty):
""",
)

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 validate_values(self, logger: 'BoundLogger') -> Optional[pint.Quantity]:
"""
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.
"""
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]:
def momentum_to_type(self, mtr, logger: 'BoundLogger') -> Optional[str]:
"""
Resolves the `type` of the electronic band gap based on the stored `momentum_transfer` values.

Expand All @@ -101,23 +71,7 @@ def resolve_type(self, logger: 'BoundLogger') -> Optional[str]:
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():
Expand All @@ -128,17 +82,9 @@ def resolve_type(self, logger: 'BoundLogger') -> Optional[str]:
def normalize(self, archive: 'EntryArchive', logger: 'BoundLogger') -> None:
super().normalize(archive, logger)

# Checks if the `value` is negative and sets it to None if it is.
self.value = self.validate_values(logger)
if self.value is None:
if self.value is not None and np.any(self.value < 0.):
logger.warning('The electronic band gap cannot be defined negative.')
# ? 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)

if self.momentum_transfer:
self.type = self.momentum_to_type(self.momentum_transfer, logger)
Loading
Loading