diff --git a/express/properties/non_scalar/non_scalar_property_context.py b/express/properties/non_scalar/non_scalar_property_context.py new file mode 100644 index 00000000..1ba57e79 --- /dev/null +++ b/express/properties/non_scalar/non_scalar_property_context.py @@ -0,0 +1,38 @@ +from typing import Any, Type + +from mat3ra.esse.utils import validate_and_clean + +from express.parsers import BaseParser +from express.properties.non_scalar import NonScalarProperty + + +class NonScalarPropertyFromContext(NonScalarProperty): + def __init__( + self, + name: str, + parser: Type[BaseParser], + data: Any = None, + context: dict[str, Any] | None = None, + context_key: str | None = None, + *args, + **kwargs, + ): + super().__init__(name, parser, *args, **kwargs) + if data is not None: + self.data = data + elif "value" in kwargs: + self.data = kwargs["value"] + else: + self.data = context[context_key or name] + + def _serialize(self): + if isinstance(self.data, dict): + return {"name": self.name, **self.data} + return {"name": self.name, "values": self.data} + + def serialize_and_validate(self): + instance = self._serialize() + result = validate_and_clean(instance, self.schema) + if not result["is_valid"]: + raise result["errors"][0] + return instance diff --git a/express/settings.py b/express/settings.py index 4e545d90..550af34f 100644 --- a/express/settings.py +++ b/express/settings.py @@ -36,6 +36,9 @@ "total_energy_contributions": { "reference": "express.properties.non_scalar.total_energy_contributions.TotalEnergyContributions" }, + "formation_energy_contributions": { + "reference": "express.properties.non_scalar.non_scalar_property_context.NonScalarPropertyFromContext" + }, "material": {"reference": "express.properties.material.Material"}, "symmetry": {"reference": "express.properties.non_scalar.symmetry.Symmetry"}, "workflow:pyml_predict": {"reference": "express.properties.workflow.PyMLTrainAndPredictWorkflow"}, diff --git a/pyproject.toml b/pyproject.toml index 0f3a4d81..89653c2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ dependencies = [ "pymatgen>=2023.8.10", "ase>=3.17.0", - "mat3ra-esse>=2026.5.30.post0", + "mat3ra-esse>=2026.6.12", "jarvis-tools>=2023.12.12", # To avoid module 'numpy.linalg._umath_linalg' has no attribute '_ilp64' in Colab "numpy>=1.24.4,<2", diff --git a/tests/unit/properties/non_scalar/test_formation_energy_contributions.py b/tests/unit/properties/non_scalar/test_formation_energy_contributions.py new file mode 100644 index 00000000..d4da8765 --- /dev/null +++ b/tests/unit/properties/non_scalar/test_formation_energy_contributions.py @@ -0,0 +1,101 @@ +from tests.unit import UnitTestBase + +from express.properties.non_scalar.non_scalar_property_context import NonScalarPropertyFromContext + +COMPOUND_CONTRIBUTION = { + "formula": "SiC", + "n_atoms": 4, + "is_elemental": False, + "total_energy": -520.003969643439, + "total_energy_per_atom": -130.00099241085975, + "precision_value": 8192, + "precision_metric": "KPPRA", +} +SILICON_CONTRIBUTION = { + "formula": "Si", + "n_atoms": 2, + "is_elemental": True, + "total_energy": -261.003969643439, + "total_energy_per_atom": -130.5019848217195, + "precision_value": 8192, + "precision_metric": "KPPRA", +} +FORMATION_ENERGY_CONTRIBUTIONS_VALUES = [COMPOUND_CONTRIBUTION, SILICON_CONTRIBUTION] +FORMATION_ENERGY_CONTRIBUTIONS = { + "name": "formation_energy_contributions", + "values": FORMATION_ENERGY_CONTRIBUTIONS_VALUES, +} +COMPOUND_CONTRIBUTION_WITHOUT_PRECISION = { + "formula": "SiC", + "n_atoms": 4, + "is_elemental": False, + "total_energy": -520.003969643439, + "total_energy_per_atom": -130.00099241085975, +} +DEFAULT_PRECISION_VALUE = -1 +DEFAULT_PRECISION_METRIC = "unknown" +COMPOUND_CONTRIBUTION_WITH_DEFAULT_PRECISION = { + **COMPOUND_CONTRIBUTION_WITHOUT_PRECISION, + "precision_value": DEFAULT_PRECISION_VALUE, + "precision_metric": DEFAULT_PRECISION_METRIC, +} + + +class FormationEnergyContributionsTest(UnitTestBase): + def setUp(self): + super().setUp() + + def tearDown(self): + super().tearDown() + + def test_formation_energy_contributions_from_context_values(self): + property_ = NonScalarPropertyFromContext( + "formation_energy_contributions", + None, + data=FORMATION_ENERGY_CONTRIBUTIONS_VALUES, + ) + + self.assertDeepAlmostEqual(property_.serialize_and_validate(), FORMATION_ENERGY_CONTRIBUTIONS) + + def test_formation_energy_contributions_from_context_data(self): + property_ = NonScalarPropertyFromContext( + "formation_energy_contributions", + None, + data={"values": FORMATION_ENERGY_CONTRIBUTIONS_VALUES}, + ) + + self.assertDeepAlmostEqual(property_.serialize_and_validate(), FORMATION_ENERGY_CONTRIBUTIONS) + + def test_formation_energy_contributions_from_value_alias(self): + property_ = NonScalarPropertyFromContext( + "formation_energy_contributions", + None, + value=FORMATION_ENERGY_CONTRIBUTIONS_VALUES, + ) + + self.assertDeepAlmostEqual(property_.serialize_and_validate(), FORMATION_ENERGY_CONTRIBUTIONS) + + def test_formation_energy_contributions_applies_schema_defaults(self): + property_ = NonScalarPropertyFromContext( + "formation_energy_contributions", + None, + data=[COMPOUND_CONTRIBUTION_WITHOUT_PRECISION], + ) + + self.assertDeepAlmostEqual( + property_.serialize_and_validate(), + { + "name": "formation_energy_contributions", + "values": [COMPOUND_CONTRIBUTION_WITH_DEFAULT_PRECISION], + }, + ) + + def test_formation_energy_contributions_from_context_key(self): + property_ = NonScalarPropertyFromContext( + "formation_energy_contributions", + None, + context={"FORMATION_ENERGY_REFERENCES": FORMATION_ENERGY_CONTRIBUTIONS_VALUES}, + context_key="FORMATION_ENERGY_REFERENCES", + ) + + self.assertDeepAlmostEqual(property_.serialize_and_validate(), FORMATION_ENERGY_CONTRIBUTIONS)