Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 63 additions & 38 deletions mp_api/client/mprester.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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],
Expand All @@ -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
Expand Down Expand Up @@ -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,
)
Expand All @@ -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,
)
)
Expand All @@ -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
Expand Down
59 changes: 50 additions & 9 deletions tests/client/test_mprester.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.",
Expand Down
Loading