From 5d30c4212f3998dbb4d895df79c377c70358ebe1 Mon Sep 17 00:00:00 2001 From: Sean Kavanagh Date: Sun, 23 Aug 2026 10:44:16 +0100 Subject: [PATCH] Serve the pre-built phase diagram for property_data / conventional_unit_cell queries (with the additions/corrections applied afterwards); plus fixes and a test --- mp_api/client/mprester.py | 101 +++++++++++++++++++++------------- tests/client/test_mprester.py | 59 +++++++++++++++++--- 2 files changed, 113 insertions(+), 47 deletions(-) diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py index 3a9db361..0ef8e015 100644 --- a/mp_api/client/mprester.py +++ b/mp_api/client/mprester.py @@ -6,6 +6,7 @@ import re import warnings from collections import defaultdict +from copy import deepcopy from functools import cache, lru_cache from typing import TYPE_CHECKING from urllib.parse import urlencode @@ -675,29 +676,13 @@ def get_entries( ): # merge property_data, retaining entry data (e.g. `oxidation_states`) entry_dict["data"] |= {prop: doc[prop] for prop in property_data} + entry = TypeAdapter(ComputedStructureEntryType).validate_python( + entry_dict + ) if conventional_unit_cell: - entry_struct = Structure.from_dict(entry_dict["structure"]) - s = SpacegroupAnalyzer( - entry_struct - ).get_conventional_standard_structure() - site_ratio = len(s) / len(entry_struct) - new_energy = entry_dict["energy"] * site_ratio - - entry_dict["energy"] = new_energy - entry_dict["structure"] = s.as_dict() - entry_dict["correction"] = 0.0 - - for element in entry_dict["composition"]: - entry_dict["composition"][element] *= site_ratio + entry = self._get_conventional_cell_entry(entry) - for correction in entry_dict["energy_adjustments"]: - if "n_atoms" in correction: - correction["n_atoms"] *= site_ratio - - # Need to store object to permit de-duplication - entries.add( - TypeAdapter(ComputedStructureEntryType).validate_python(entry_dict) - ) + entries.add(entry) # object permits de-duplication return list(entries) @@ -1089,6 +1074,31 @@ def _get_unmixed_entries( ) ] + @staticmethod + def _get_conventional_cell_entry( + entry: ComputedStructureEntry, + ) -> ComputedStructureEntry: + """Rebuild ``entry`` on the standard conventional unit cell, scaling the energy + and energy adjustments accordingly. + """ + conventional_structure = SpacegroupAnalyzer( + entry.structure + ).get_conventional_standard_structure() + site_ratio = len(conventional_structure) / len(entry.structure) + + energy_adjustments = deepcopy(entry.energy_adjustments) + for adjustment in energy_adjustments: # adjustment values are extensive + adjustment.normalize(1 / site_ratio) + + return ComputedStructureEntry( + conventional_structure, + entry.uncorrected_energy * site_ratio, + energy_adjustments=energy_adjustments, + parameters=entry.parameters, + data=entry.data, + entry_id=entry.entry_id, + ) + def get_entries_in_chemsys( self, elements: str | list[str], @@ -1110,11 +1120,11 @@ def get_entries_in_chemsys( Mixed entries are taken from the MP-built phase diagram for the whole chemical system, so they share one energy scale and reproduce the hull shown on - https://materialsproject.org. Narrowing the query with `additional_criteria`, + https://materialsproject.org; ``property_data`` fields are attached to the + served entries after the fact. Narrowing the query with `additional_criteria`, or passing ``compatible_only = False``, cannot be served that way and returns - entries that are *not* immediately suitable for constructing a phase diagram; - ``property_data`` and ``conventional_unit_cell`` re-apply the mixing scheme here - instead, which can differ slightly from MP. Warnings are thrown for these cases. + entries that are *not* immediately suitable for constructing a phase diagram. + Warnings are thrown for these cases. Args: elements (str or [str]): Parent chemical system string comprising element @@ -1187,19 +1197,31 @@ def get_entries_in_chemsys( entries: list[ComputedStructureEntry] | None = None if consistent: - if not (property_data or conventional_unit_cell): - phase_diagram = self.materials.thermo.get_phase_diagram_from_chemsys( - "-".join(sorted(elements_set)), - thermo_type=additional_criteria["thermo_types"][0], - ) # default, mixed thermotype; takes a single type, not a list - if phase_diagram is not None: - entries = list(phase_diagram.all_entries) + phase_diagram = self.materials.thermo.get_phase_diagram_from_chemsys( + "-".join(sorted(elements_set)), + thermo_type=additional_criteria["thermo_types"][0], + ) # default, mixed thermotype; takes a single type, not a list + if phase_diagram is not None: + entries = list(phase_diagram.all_entries) + + if property_data: # decorate the served entries post-hoc + docs = self.materials.thermo.search( + chemsys=all_chemsyses, + thermo_types=additional_criteria["thermo_types"], + all_fields=False, + fields=["material_id", *property_data], + ) + props = { + str(doc["material_id"]): {p: doc[p] for p in property_data} + for doc in docs + } + for entry in entries: # served entries carry `material_id` + entry.data |= props[str(entry.data["material_id"])] if entries is None: - # MP has no pre-built diagram for this system, or the entries need reshaping - # first, so redo the mixing here as MP does when building PDs. Mixing scheme - # is chemical-system dependent, so this can anchor on a different hull than - # MP did/would, and it drops entries it cannot place: + # MP has no pre-built diagram for this system, so redo the mixing here as MP does when + # building PDs. Mixing scheme is chemical-system dependent, so this can anchor on a + # different hull than MP did/would, and it drops entries it cannot place: from pymatgen.entries.mixing_scheme import ( MaterialsProjectDFTMixingScheme, ) @@ -1217,7 +1239,6 @@ def get_entries_in_chemsys( self._get_unmixed_entries( all_chemsyses, property_data=property_data, - conventional_unit_cell=conventional_unit_cell, **kwargs, ) ) @@ -1238,11 +1259,15 @@ def get_entries_in_chemsys( all_chemsyses, compatible_only=compatible_only, property_data=property_data, - conventional_unit_cell=conventional_unit_cell, additional_criteria=additional_criteria, **kwargs, ) + if conventional_unit_cell: + # reshaped here rather than in the queries above, so that structure matching in the mixing + # scheme sees the original cells, and so that every energy adjustment is scaled appropriately: + entries = [self._get_conventional_cell_entry(entry) for entry in entries] + if use_gibbs: # replace the entries with GibbsComputedStructureEntry from pymatgen.entries.computed_entries import GibbsComputedStructureEntry diff --git a/tests/client/test_mprester.py b/tests/client/test_mprester.py index e1abd9b8..1fb46670 100644 --- a/tests/client/test_mprester.py +++ b/tests/client/test_mprester.py @@ -29,7 +29,11 @@ MaterialsProject2020Compatibility, MaterialsProjectAqueousCompatibility, ) -from pymatgen.entries.computed_entries import ComputedEntry, GibbsComputedStructureEntry +from pymatgen.entries.computed_entries import ( + ComputedEntry, + ConstantEnergyAdjustment, + GibbsComputedStructureEntry, +) from pymatgen.entries.mixing_scheme import MaterialsProjectDFTMixingScheme from pymatgen.io.cif import CifParser from pymatgen.io.vasp import Chgcar @@ -236,15 +240,14 @@ def test_get_entries(self, mpr): non_standardized = mpr.get_entry_by_material_id( thermo_docs[3].material_id, conventional_unit_cell=False ) - assert all( - e.uncorrected_energy_per_atom - == pytest.approx( - next( - f for f in non_standardized if f.entry_id == e.entry_id - ).uncorrected_energy_per_atom + for e in as_conv: + ref = next(f for f in non_standardized if f.entry_id == e.entry_id) + assert e.uncorrected_energy_per_atom == pytest.approx( + ref.uncorrected_energy_per_atom ) - for e in as_conv - ) + # corrected too: every adjustment must scale with the cell, including + # extensive ones with no ``n_atoms``, e.g. the r2SCAN mixing correction + assert e.energy_per_atom == pytest.approx(ref.energy_per_atom) # Additional criteria entry = mpr.get_entries( @@ -339,6 +342,44 @@ def test_get_entries_in_chemsys_mixed_hull(self, mpr): "Cs-Ti-I", additional_criteria={"is_stable": True} ) + def test_get_entries_in_chemsys_decorated_served_pd(self, mpr): + """ + ``property_data`` / ``conventional_unit_cell`` requests are also served from + the pre-built phase diagram (and decorated post-hoc), rather than falling back + to re-applying the mixing scheme locally (which can differ from MP's hull). + """ + entries = mpr.get_entries_in_chemsys("H-O") + decorated = mpr.get_entries_in_chemsys( + "H-O", property_data=["energy_above_hull"], conventional_unit_cell=True + ) + served = { + str(e.entry_id): ( + e.energy_per_atom, + len(e.structure), + e.composition.reduced_formula, + ) + for e in entries + } + + # same served entry set, same energy scale, and the reshaping is not a no-op + assert {str(e.entry_id) for e in decorated} == set(served) + assert any(len(e.structure) != served[str(e.entry_id)][1] for e in decorated) + # not vacuous: served r2SCAN entries carry their mixing correction as an + # extensive ``ConstantEnergyAdjustment``, which has no ``n_atoms`` to scale + assert any( + isinstance(adj, ConstantEnergyAdjustment) + for e in entries + for adj in e.energy_adjustments + ) + + for entry in decorated: + energy, _, formula = served[str(entry.entry_id)] + assert entry.data["energy_above_hull"] >= 0 + # conventional reshaping must preserve per-atom corrected energies (including + # the extensive mixing-scheme adjustments on r2SCAN entries) and stoichiometry: + assert entry.energy_per_atom == pytest.approx(energy, abs=1e-8) + assert entry.composition.reduced_formula == formula + @pytest.mark.skipif( contribs_client is None, reason="`pip install 'mp-api[contribs]'` to use pourbaix functionality.",