From 5dd7e77f357e7febfc116df85101bad4fb302643 Mon Sep 17 00:00:00 2001 From: Sean Kavanagh Date: Sun, 9 Aug 2026 17:10:02 +0100 Subject: [PATCH 1/5] Keep entry data when property_data is requested in get_entries `get_entries` replaced `entry.data` wholesale with the requested `property_data` fields, discarding everything the thermo doc served with the entry -- including `oxidation_states`. Nothing downstream noticed while corrections came pre-applied from the server, but any client-side re-application of `MaterialsProject2020Compatibility` then has to re-guess the oxidation states, and gets them wrong for some peroxides and superoxides: mp-761189 picks up a superoxide instead of a peroxide anion correction, shifting its energy by 1.824 eV (0.182 eV/atom). --- mp_api/client/mprester.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py index 6dba586e..eaea3216 100644 --- a/mp_api/client/mprester.py +++ b/mp_api/client/mprester.py @@ -658,7 +658,9 @@ def get_entries( entry_dict["energy_adjustments"] = [] if property_data: - entry_dict["data"] = {prop: doc[prop] for prop in property_data} + # merge, don't replace: `data` carries the `oxidation_states` that a + # client-side MaterialsProject2020Compatibility needs for anion corrections + entry_dict["data"] |= {prop: doc[prop] for prop in property_data} if conventional_unit_cell: entry_struct = Structure.from_dict(entry_dict["structure"]) From c80c135f8123f3aa5de08e03bb0b5e65bc50d325 Mon Sep 17 00:00:00 2001 From: Sean Kavanagh Date: Sun, 9 Aug 2026 17:10:21 +0100 Subject: [PATCH 2/5] Apply the default thermo type alongside additional_criteria, not instead of it `additional_criteria or DEFAULT_THERMOTYPE_CRITERIA` dropped the default thermo type as soon as the caller passed any criteria of their own. A call such as get_entries_in_chemsys("Li-Fe-O", additional_criteria={"is_stable": True}) therefore ran with no thermo type filter at all and pooled GGA_GGA+U, R2SCAN and GGA_GGA+U_R2SCAN entries together, returning the same material up to three times at mutually inconsistent energies (26 entries where 11 were meant). Merge the two instead, so an explicit `thermo_types` still wins but every other criterion narrows the documented default rather than removing it. --- mp_api/client/mprester.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py index eaea3216..fd424dff 100644 --- a/mp_api/client/mprester.py +++ b/mp_api/client/mprester.py @@ -1133,12 +1133,18 @@ def get_entries_in_chemsys( stacklevel=2, ) + # the thermo type default applies unless the caller overrides it explicitly + additional_criteria = { + **DEFAULT_THERMOTYPE_CRITERIA, + **(additional_criteria or {}), + } + entries = self.get_entries( all_chemsyses, compatible_only=compatible_only, property_data=property_data, conventional_unit_cell=conventional_unit_cell, - additional_criteria=additional_criteria or DEFAULT_THERMOTYPE_CRITERIA, + additional_criteria=additional_criteria, **kwargs, ) From f451a5235101d0b3c8f964ca0f6f1ddcb51e7526 Mon Sep 17 00:00:00 2001 From: Sean Kavanagh Date: Sun, 9 Aug 2026 17:57:37 +0100 Subject: [PATCH 3/5] Return mixed GGA(+U)/r2SCAN entries on a single energy scale Fixes #1104. emmet's thermo builder runs with `use_max_chemsys=True`, so a material's ThermoDoc is only emitted by the build for its own chemical system: elemental Cs comes from the `Cs` build, CsI from `Cs-I`, Cs2TiI6 from `Cs-I-Ti`. For the mixed GGA_GGA+U_R2SCAN thermo type the stored `ConstantEnergyAdjustment` is referenced to *that* build's hull, and `MaterialsProjectDFTMixingScheme` chooses its reference functional per chemical system. `get_entries_in_chemsys` pools the docs of every subsystem, so it mixes entries that sit on two different absolute energy scales. For Cs-Ti-I the elemental Cs and Ti docs are anchored on r2SCAN (-25.1 and -12.9 eV/atom) while everything containing I is anchored on GGA(+U), which puts Cs2TiI6 4.574 eV/atom above the hull instead of on it. No single stored adjustment can fix this: a hull-referenced correction is only meaningful relative to the hull it was computed on, and a material belongs to many chemical systems. So take the entries from the phase diagram MP builds for the whole system instead, where the mixing was applied across every subsystem at once. Those entries are self-consistent by construction and reproduce the hull shown on materialsproject.org exactly: hull distances match the served `energy_above_hull` to 0.0 eV/atom with no material missing, across Cs-Ti-I, H-O, Na-Cl, Ba-Ti-O, Sr-Ti-O, Zn-O, Li-Mn-O, Cs-Pb-I, Ag-Bi-I, Y-Ba-Cu-O and Fe. Where MP has no pre-built diagram (a chemical system with no material of that dimensionality, e.g. Ba-Cs-I-Ti), or the entries need reshaping first, fall back to re-applying the mixing scheme here. That is what MP does when it builds a diagram, but it is chemical-system dependent, so it can anchor on a different hull than MP did and it drops entries it cannot place -- for H-O it loses 14 materials and puts H2O2 0.176 eV/atom off. The fallback therefore warns. Queries that cannot be served on one scale at all -- narrowed by `additional_criteria`, or `compatible_only=False` -- now warn instead of silently returning entries that look usable for a phase diagram. Costs about 6 s more for small systems, where the phase diagram download dominates; roughly flat with system size, so large systems are unaffected. --- mp_api/client/mprester.py | 126 +++++++++++++++++++++++++++++----- tests/client/test_mprester.py | 43 ++++++++++++ 2 files changed, 150 insertions(+), 19 deletions(-) diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py index fd424dff..d584aaa0 100644 --- a/mp_api/client/mprester.py +++ b/mp_api/client/mprester.py @@ -43,7 +43,7 @@ from mp_api.client.routes.molecules import MOLECULES_RESTERS if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Iterable, Sequence from typing import Any, Literal import numpy as np @@ -88,6 +88,16 @@ ] +def _all_subchemsyses(elements: Iterable[str]) -> list[str]: + """Every chemical (sub)system spanned by `elements`, as sorted dash-joined strings.""" + element_set = set(elements) + return [ + "-".join(sorted(els)) + for n in range(1, len(element_set) + 1) + for els in itertools.combinations(element_set, n) + ] + + class MPRester(_Rester): """Access the new Materials Project API.""" @@ -657,9 +667,7 @@ def get_entries( entry_dict["correction"] = 0.0 entry_dict["energy_adjustments"] = [] - if property_data: - # merge, don't replace: `data` carries the `oxidation_states` that a - # client-side MaterialsProject2020Compatibility needs for anion corrections + if property_data: # merge property_data, retaining entry data (e.g. `oxidation_states`) entry_dict["data"] |= {prop: doc[prop] for prop in property_data} if conventional_unit_cell: @@ -1056,6 +1064,26 @@ def get_entry_by_material_id( **kwargs, ) + def _get_unmixed_entries( + self, chemsyses: list[str], **kwargs + ) -> list[ComputedStructureEntry]: + """Get the GGA(+U) and r2SCAN entries for `chemsyses` as separate, un-mixed entries. + + `MaterialsProjectDFTMixingScheme` needs both functionals side by side, as returned + by this helper function, while entries returned by the mixed thermo type cannot be + used with it. + """ + return [ + entry + for thermo_type in (ThermoType.GGA_GGA_U, ThermoType.R2SCAN) + for entry in self.get_entries( + chemsyses, + compatible_only=True, + additional_criteria={"thermo_types": [thermo_type.value]}, + **kwargs, + ) + ] + def get_entries_in_chemsys( self, elements: str | list[str], @@ -1075,6 +1103,14 @@ def get_entries_in_chemsys( Note that by default this returns mixed GGA/GGA+U/r2SCAN entries. For others, pass GGA/GGA+U, or R2SCAN as thermo_types in additional_criteria. + 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`, + 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. + Args: elements (str or [str]): Parent chemical system string comprising element symbols separated by dashes, e.g., "Li-Fe-O" or List of element @@ -1116,11 +1152,7 @@ def get_entries_in_chemsys( "or identify a subset of relevant chemical systems to query first." ) - all_chemsyses = [ - "-".join(sorted(els)) - for i in range(len(elements_set)) - for els in itertools.combinations(elements_set, i + 1) - ] + all_chemsyses = _all_subchemsyses(elements_set) if additional_criteria is None: warnings.warn( @@ -1133,20 +1165,76 @@ def get_entries_in_chemsys( stacklevel=2, ) - # the thermo type default applies unless the caller overrides it explicitly additional_criteria = { **DEFAULT_THERMOTYPE_CRITERIA, **(additional_criteria or {}), - } + } # default thermo type unless the caller explicitly overrides + + # The mixing correction stored on a mixed GGA(+U)/r2SCAN entry is referenced to the hull + # of the one chemical system that entry's thermo doc was built for, so the served entries + # cannot be pooled _across subsystems_ -- they end up on different absolute energy scales + # (issue #1104). Thus we serve MP's phase diagram; built with mixing applied across the + # full system, thus self-consistent by construction and identical to the MP website: + mixed = set(additional_criteria["thermo_types"]) == {"GGA_GGA+U_R2SCAN"} + consistent = mixed and compatible_only and set(additional_criteria) == {"thermo_types"} + + 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) + + 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: + from pymatgen.entries.mixing_scheme import ( + MaterialsProjectDFTMixingScheme, + ) - entries = self.get_entries( - all_chemsyses, - compatible_only=compatible_only, - property_data=property_data, - conventional_unit_cell=conventional_unit_cell, - additional_criteria=additional_criteria, - **kwargs, - ) + warnings.warn( + "Reconstructing a common energy scale for these entries with the " + "GGA(+U)/r2SCAN mixing scheme, as the Materials Project has no pre-built " + "phase diagram to serve for this query. Energies and hull distances may " + "differ slightly from https://materialsproject.org, and entries the mixing " + "scheme cannot place are dropped.", + category=MPRestWarning, + stacklevel=2, + ) + entries = MaterialsProjectDFTMixingScheme().process_entries( + self._get_unmixed_entries( + all_chemsyses, + property_data=property_data, + conventional_unit_cell=conventional_unit_cell, + **kwargs, + ) + ) + else: # non-consistent + if mixed: + warnings.warn( + "Mixed GGA(+U)/r2SCAN entries can only be placed on a common energy scale " + "when the whole chemical system is retrieved with `compatible_only = True`, " + "so these entries are not suitable for constructing a phase diagram. Either " + "drop the extra `additional_criteria` (and filter the returned entries " + "instead), or request a single functional with " + '`additional_criteria = {"thermo_types": ["GGA_GGA+U"]}`.', + category=MPRestWarning, + stacklevel=2, + ) + + entries = self.get_entries( + all_chemsyses, + compatible_only=compatible_only, + property_data=property_data, + conventional_unit_cell=conventional_unit_cell, + additional_criteria=additional_criteria, + **kwargs, + ) if use_gibbs: # replace the entries with GibbsComputedStructureEntry diff --git a/tests/client/test_mprester.py b/tests/client/test_mprester.py index df995d3a..f137b4c5 100644 --- a/tests/client/test_mprester.py +++ b/tests/client/test_mprester.py @@ -2,6 +2,7 @@ import itertools import os import random +from collections import defaultdict from tempfile import NamedTemporaryFile import numpy as np @@ -296,6 +297,48 @@ def test_get_entries_in_chemsys(self, mpr): ): mpr.get_entries_in_chemsys([Element.from_Z(1 + i).name for i in range(10)]) + def test_get_entries_in_chemsys_mixed_hull(self, mpr): + """Mixed GGA(+U)/r2SCAN entries must all sit on one energy scale (issue #1104). + + The mixing correction served with an entry is referenced to the hull of the single + chemical system that entry's thermo doc was built for, so pooling the served entries + across subsystems put Cs2TiI6 ~4.6 eV/atom above the hull instead of on it. + """ + entries = mpr.get_entries_in_chemsys("Cs-Ti-I") + phase_diagram = PhaseDiagram(entries) + host = next(e for e in entries if e.composition.reduced_formula == "Cs2TiI6") + assert phase_diagram.get_e_above_hull(host) == pytest.approx(0.0, abs=1e-6) + + # hull distances must match the ones MP serves, and no material may go missing -- + # both fail if this silently falls through to re-applying the mixing scheme here + docs = mpr.materials.thermo.search( + chemsys=["H-O"], + thermo_types=[ThermoType.GGA_GGA_U_R2SCAN], + all_fields=False, + fields=["material_id", "energy_above_hull"], + ) + entries = mpr.get_entries_in_chemsys("H-O") + phase_diagram = PhaseDiagram(entries) + by_mpid = defaultdict(list) + for entry in entries: + by_mpid[str(entry.data["material_id"])].append(entry) + for doc in docs: + hull_entries = by_mpid[str(doc.material_id)] + assert hull_entries, f"{doc.material_id} missing from the returned entries" + # a material can have one entry per run type; the served hull distance is the one + # for whichever entry MP blessed + assert any( + phase_diagram.get_e_above_hull(e) + == pytest.approx(doc.energy_above_hull, abs=1e-4) + for e in hull_entries + ) + + # a narrowed query cannot be placed on a common scale, so it must say so + with pytest.warns(MPRestWarning, match="common energy scale"): + mpr.get_entries_in_chemsys( + "Cs-Ti-I", additional_criteria={"is_stable": True} + ) + @pytest.mark.skipif( contribs_client is None, reason="`pip install 'mp-api[contribs]'` to use pourbaix functionality.", From 9a917b6e865afd0e2952263406839e8b1fc746bb Mon Sep 17 00:00:00 2001 From: Sean Kavanagh Date: Sun, 9 Aug 2026 17:57:37 +0100 Subject: [PATCH 4/5] Mix user entries in get_stability against un-mixed entries For the mixed GGA_GGA+U_R2SCAN hull, `get_stability` re-ran `MaterialsProjectDFTMixingScheme` on `PhaseDiagramDoc.all_entries`. Those hold one already-mixed entry per material, so no material carries both run types, the scheme finds no GGA(+U)/r2SCAN pairs to match, and it discards almost everything it is given: for Cs-Ti-I, 48 entries in, 19 out, with all 29 r2SCAN entries and every ternary gone. A user entry for a phase that really is on the hull then failed with `ValueError: No valid decomposition found`. Feed it the un-mixed GGA(+U) and r2SCAN entries instead, which is the input the mixing scheme is designed for. An r2SCAN entry for Cs2TiI6 now returns 0.0 eV/atom above the hull, and mp-30951 reproduces the served `energy_above_hull` of 0.111257. Note this remains a client-side reconstruction, so it can disagree with `get_entries_in_chemsys` -- which now mirrors MP's own phase diagram -- on systems where the mixing scheme anchors differently than MP's build did. Placing a user entry on MP's hull needs both functionals side by side, which the pre-built diagram does not carry. This is the same misunderstanding behind the `test_get_stability` skip ("SOMETHING IS OFF HERE FOR THE MIXING SCHEME"). That test still cannot be re-enabled for the mixed thermo type: its own reference re-mixes already-mixed entries, and its bare `ComputedEntry` inputs have no structures for the scheme to match on. --- mp_api/client/mprester.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py index d584aaa0..4e128f6e 100644 --- a/mp_api/client/mprester.py +++ b/mp_api/client/mprester.py @@ -1746,8 +1746,14 @@ def get_stability( joint_entries: Sequence[ComputedEntry | ComputedStructureEntry | PDEntry] = [ *entries, - *pd.all_entries, - ] + *( + self._get_unmixed_entries(_all_subchemsyses(str(el) for el in chemsys)) + if thermo_type_valid_str == ThermoType.GGA_GGA_U_R2SCAN.value + else pd.all_entries + ), # `pd.all_entries` for a mixed hull hold one already-mixed entry per material, + ] # which the mixing scheme cannot re-process (needs the GGA(+U) and r2SCAN entries + # side by side), and silently drops every entry it cannot pair up -- so we fetch + # separately in the mixed case new_pd = PhaseDiagram( corrector.process_entries(joint_entries) # type: ignore[arg-type] From 36820f8d64bb0621ed13d433fefc0280505ed0aa Mon Sep 17 00:00:00 2001 From: Sean Kavanagh Date: Sun, 9 Aug 2026 18:35:17 +0100 Subject: [PATCH 5/5] Pre-commit formatting --- mp_api/client/mprester.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py index 4e128f6e..551f6852 100644 --- a/mp_api/client/mprester.py +++ b/mp_api/client/mprester.py @@ -667,7 +667,9 @@ def get_entries( entry_dict["correction"] = 0.0 entry_dict["energy_adjustments"] = [] - if property_data: # merge property_data, retaining entry data (e.g. `oxidation_states`) + if ( + property_data + ): # merge property_data, retaining entry data (e.g. `oxidation_states`) entry_dict["data"] |= {prop: doc[prop] for prop in property_data} if conventional_unit_cell: @@ -1176,7 +1178,9 @@ def get_entries_in_chemsys( # (issue #1104). Thus we serve MP's phase diagram; built with mixing applied across the # full system, thus self-consistent by construction and identical to the MP website: mixed = set(additional_criteria["thermo_types"]) == {"GGA_GGA+U_R2SCAN"} - consistent = mixed and compatible_only and set(additional_criteria) == {"thermo_types"} + consistent = ( + mixed and compatible_only and set(additional_criteria) == {"thermo_types"} + ) entries: list[ComputedStructureEntry] | None = None if consistent: