From 00a6bb90914d1652faa8911f7e4ab8769ed2e072 Mon Sep 17 00:00:00 2001 From: Sean Kavanagh Date: Tue, 20 Oct 2026 11:03:16 +0100 Subject: [PATCH 1/3] Only serve GNoMe entries of pre-built phase diagrams to API keys with GNoMe access --- mp_api/client/core/client.py | 60 ++++++++++++------ mp_api/client/mprester.py | 19 +++++- mp_api/client/routes/materials/thermo.py | 80 ++++++++++++++++++++++++ tests/client/materials/test_thermo.py | 39 ++++++++++++ tests/client/test_mprester.py | 70 +++++++++++++++++++++ 5 files changed, 246 insertions(+), 22 deletions(-) diff --git a/mp_api/client/core/client.py b/mp_api/client/core/client.py index 3fbf9644..434b305a 100644 --- a/mp_api/client/core/client.py +++ b/mp_api/client/core/client.py @@ -486,6 +486,7 @@ def __init__( self.timeout = timeout self._s3_client = s3_client + self._gnome_access: bool | None = None @property def s3_client(self): @@ -896,6 +897,45 @@ def _check_rest_db_version(self, db_version: str) -> None: "to download that version, then filter it locally." ) + def _has_gnome_access(self, timeout: int | None = None) -> bool: + """Whether the API key can access the GNoMe (BY-NC licensed) data. + + Data in access-controlled collections that the key cannot access is not + served by the API, so is left out of full dataset downloads and phase + diagrams too. Checked with one request on first use, then cached; a failed + check (e.g. no API key) is treated as no access. + + Args: + timeout (int or None) : timeout for the check, defaults to `self.timeout` + + Returns: + bool + """ + if self._gnome_access is None: + try: + self._gnome_access = bool( + self._submit_requests( + url=urljoin(self.base_endpoint, "materials/summary/"), + criteria={ + "batch_id": "gnome_r2scan_statics", + "_fields": "material_id", + }, + use_document_model=False, + num_chunks=1, + chunk_size=1, + timeout=timeout if timeout is not None else self.timeout, + show_progress=False, + ) + .get("meta", {}) + .get("total_doc", 0) + ) + except MPRestError as exc: + logger.debug( + f"Could not check access to GNoMe data, assuming none: {exc}" + ) + self._gnome_access = False + return self._gnome_access + def _query_delta_backed( self, bucket: str, @@ -995,28 +1035,10 @@ def _dataset() -> dict[str, Any]: f"Regenerating {collection}{version_note} at {target_path}..." ) - # Check if user has access to GNoMe - has_gnome_access = bool( - self._submit_requests( - url=urljoin(self.base_endpoint, "materials/summary/"), - criteria={ - "batch_id": "gnome_r2scan_statics", - "_fields": "material_id", - }, - use_document_model=False, - num_chunks=1, - chunk_size=1, - timeout=timeout if timeout is not None else self.timeout, - show_progress=False, - ) - .get("meta", {}) - .get("total_doc", 0) - ) - conditions = [] if versioned: conditions.append(f"version = '{version}'") - filter_access = access_controlled and not has_gnome_access + filter_access = access_controlled and not self._has_gnome_access(timeout) if filter_access: if collection == "tasks": controlled_batch_str = ",".join( diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py index 0fab2eca..9d5ac435 100644 --- a/mp_api/client/mprester.py +++ b/mp_api/client/mprester.py @@ -62,7 +62,7 @@ DEFAULT_THERMOTYPE_CRITERIA, MAPI_CLIENT_SETTINGS, ) -from mp_api.client.core.utils import LazyImport, load_json, validate_ids +from mp_api.client.core.utils import LazyImport, _shortlist, load_json, validate_ids from mp_api.client.routes import GENERIC_RESTERS from mp_api.client.routes.materials import MATERIALS_RESTERS from mp_api.client.routes.molecules import MOLECULES_RESTERS @@ -1295,7 +1295,9 @@ def get_entries_in_chemsys( Passing ``compatible_only = False`` cannot be served that way and returns entries that are *not* immediately suitable for constructing a phase diagram, with a warning. When MP has no pre-built diagram for the system, the mixing - scheme is re-applied locally (logged as a warning, with a progress bar). + scheme is re-applied locally (logged as a warning, with a progress bar). For API + keys without access to the GNoMe data, its entries are left out of the served + diagrams (see ``ThermoRester.get_phase_diagram_from_chemsys``). Args: elements (str or [str]): Parent chemical system string comprising element @@ -1376,8 +1378,19 @@ def get_entries_in_chemsys( str(doc["material_id"]): {p: doc[p] for p in property_data} for doc in docs } + undecorated = [] for entry in entries: # served entries carry `material_id` - entry.data |= props[str(entry.data["material_id"])] + if (mpid := str(entry.data["material_id"])) in props: + entry.data |= props[mpid] + else: # e.g. with a `db_version` other than the API's + undecorated.append(mpid) + if undecorated: + logger.warning( + f"No thermo document was found for {len(undecorated):,} of " + f"the served {'-'.join(sorted(elements_set))} phase diagram " + f"entries ({_shortlist(undecorated)}), so `property_data` " + "could not be attached to them." + ) if entries is None: # MP has no pre-built diagram for this system, so redo the mixing here as MP does when diff --git a/mp_api/client/routes/materials/thermo.py b/mp_api/client/routes/materials/thermo.py index cc2101d7..81e56803 100644 --- a/mp_api/client/routes/materials/thermo.py +++ b/mp_api/client/routes/materials/thermo.py @@ -1,6 +1,8 @@ from __future__ import annotations +import logging from collections import defaultdict +from copy import deepcopy from typing import TYPE_CHECKING import numpy as np @@ -10,6 +12,7 @@ from pydantic import TypeAdapter from pymatgen.analysis.phase_diagram import PhaseDiagram from pymatgen.core import Element +from pymatgen.entries.computed_entries import ConstantEnergyAdjustment from mp_api.client.core import BaseRester from mp_api.client.core._display import status @@ -23,6 +26,54 @@ from enums import Enum +logger = logging.getLogger(__name__) + + +def _accessible_entries(entries: list, elements: list) -> list: + """The entries of a pre-built GGA(+U)/r2SCAN phase diagram without the access- + controlled (BY-NC licensed, GNoMe) ones, with energies as if mixed without them. + + The GNoMe entries are r2SCAN-only, so they do not affect the mixing state or the + other entries' energies, except in r2SCAN-anchored systems: there the mixing scheme + places GGA(+U)-only entries onto the r2SCAN hull (shifting them by the r2SCAN minus + the GGA(+U) hull energy at their composition), which includes the GNoMe entries. + These are re-referenced to the r2SCAN hull without them, as a re-mix would give. + """ + accessible = [e for e in entries if e.data.get("license") != "BY-NC"] + shifted = [ + i + for i, e in enumerate(accessible) + if any( + "onto the r2SCAN hull" in (a.description or "") + for a in e.energy_adjustments + ) + ] + if not shifted: + return accessible + + r2scan = [ + e for e in entries if str(e.parameters.get("run_type")).lower() == "r2scan" + ] + full_hull = PhaseDiagram(r2scan, elements=elements) + accessible_hull = PhaseDiagram( + [e for e in r2scan if e.data.get("license") != "BY-NC"], elements=elements + ) + for i in shifted: + comp = accessible[i].composition + shift = accessible_hull.get_hull_energy_per_atom(comp) + shift -= full_hull.get_hull_energy_per_atom(comp) + if shift > 1e-10: # (beyond float noise) copy, to leave the served entry as is: + entry = accessible[i] = deepcopy(accessible[i]) + entry.energy_adjustments.append( + ConstantEnergyAdjustment( + shift * comp.num_atoms, + name="MP GGA(+U)/r2SCAN mixing adjustment, without GNoMe entries", + description="Place GGA(+U) energy onto the r2SCAN hull without the " + "access-controlled (GNoMe) entries", + ) + ) + return accessible + class ThermoRester(BaseRester): suffix = "materials/thermo" @@ -200,6 +251,11 @@ def get_phase_diagram_from_chemsys( ) -> PhaseDiagram: """Get a pre-computed phase diagram for a given chemsys. + For API keys without access to the GNoMe data, its (BY-NC licensed) entries + are left out, as for all other GNoMe data served to such keys, and the phase + diagram is rebuilt from the remaining entries, with the energies they would + have from the GGA(+U)/r2SCAN mixing scheme without the GNoMe entries. + Arguments: chemsys (str): A chemical system (e.g. Li-Fe-O) thermo_type (ThermoType): The thermo type for the phase diagram. @@ -246,6 +302,30 @@ def get_phase_diagram_from_chemsys( if len(pds := TypeAdapter(list[PhaseDiagramType]).validate_python(as_py)) > 0: pd = pds[0] + # The pre-built phase diagrams include the access-controlled GNoMe entries, which + # (like their thermo docs, needed e.g. for `get_entries_in_chemsys` with + # `property_data`) are otherwise not served to API keys without access to them: + if ( + pd + and any(e.data.get("license") == "BY-NC" for e in pd.all_entries) + and not self._has_gnome_access() + ): + try: + accessible = _accessible_entries(pd.all_entries, pd.elements) + n_left_out = len(pd.all_entries) - len(accessible) + pd = PhaseDiagram(accessible, elements=pd.elements) + except ValueError as exc: # e.g. missing elemental (terminal) entries + logger.warning( + f"Could not rebuild the {sorted_chemsys} phase diagram without its " + f"access-controlled (GNoMe) entries, so these are kept: {exc}" + ) + else: + logger.info( + f"Left out {n_left_out:,} access-controlled (GNoMe) entries from the " + f"{sorted_chemsys} phase diagram, as this API key does not have " + "access to GNoMe data." + ) + # Ensure el_ref keys are Element objects for PDPlotter. # Ensure qhull_data is a numpy array # This should be fixed in pymatgen diff --git a/tests/client/materials/test_thermo.py b/tests/client/materials/test_thermo.py index 51dfd66c..117ed7a8 100644 --- a/tests/client/materials/test_thermo.py +++ b/tests/client/materials/test_thermo.py @@ -69,3 +69,42 @@ def test_get_phase_diagram_from_chemsys(): pd, PhaseDiagram, ) + + +@pytest.mark.parametrize("has_access", [False, True]) +def test_get_phase_diagram_from_chemsys_gnome_access(monkeypatch, has_access): + """The (access-controlled, BY-NC licensed) GNoMe entries of the pre-built phase + diagrams are only served to API keys with access to the GNoMe data.""" + chemsys = "Cu-Se-Si" # has a GNoMe ground state (Cu3SiSe4) as of v2026.09.28 + monkeypatch.setattr( + ThermoRester, "_has_gnome_access", lambda self, timeout=None: True + ) + full = ThermoRester().get_phase_diagram_from_chemsys(chemsys) + gnome = {str(e.entry_id) for e in full.all_entries if e.data["license"] == "BY-NC"} + if not gnome: + pytest.skip(f"No GNoMe entries in the served {chemsys} phase diagram") + + monkeypatch.setattr( + ThermoRester, "_has_gnome_access", lambda self, timeout=None: has_access + ) + pd = ThermoRester().get_phase_diagram_from_chemsys(chemsys) + energies = {str(e.entry_id): e.energy_per_atom for e in pd.all_entries} + full_energies = {str(e.entry_id): e.energy_per_atom for e in full.all_entries} + if has_access: + assert energies == full_energies + return + + # the other entries keep their energies, except for GGA(+U)-only entries placed onto + # the r2SCAN hull (which are re-referenced to it without the GNoMe entries, so can + # only go up), and the hull is rebuilt without the GNoMe entries: + assert set(energies) == set(full_energies) - gnome + for entry in pd.all_entries: + i = str(entry.entry_id) + if any("without GNoMe" in adj.name for adj in entry.energy_adjustments): + assert energies[i] > full_energies[i] + else: + assert energies[i] == pytest.approx(full_energies[i]) + rebuilt = PhaseDiagram(list(pd.all_entries)) + assert {str(e.entry_id) for e in pd.stable_entries} == { + str(e.entry_id) for e in rebuilt.stable_entries + } diff --git a/tests/client/test_mprester.py b/tests/client/test_mprester.py index 1dbb8f04..70b09958 100644 --- a/tests/client/test_mprester.py +++ b/tests/client/test_mprester.py @@ -44,8 +44,10 @@ from mp_api._test_utils import requires_api_key from mp_api.client import MPRester +from mp_api.client.mprester import _all_subchemsyses from mp_api.client.core import MPRestError, MPRestWarning from mp_api.client.core.settings import _DEFAULT_ENDPOINT +from mp_api.client.routes.materials.thermo import ThermoRester try: from mp_api.client.contribs import ContribsClient as contribs_client @@ -436,6 +438,74 @@ def test_get_entries_in_chemsys_decorated_served_pd(self, mpr): assert entry.energy_per_atom == pytest.approx(energy, abs=1e-8) assert entry.composition.reduced_formula == formula + def test_get_entries_in_chemsys_gnome_access(self, mpr, monkeypatch, caplog): + """``property_data`` is attached to the served entries also for API keys + without access to the GNoMe data, whose thermo docs these keys cannot get + (previously a ``KeyError`` for any phase diagram with GNoMe entries), and a + served entry without a thermo doc no longer fails the whole query. + """ + monkeypatch.setattr( + ThermoRester, "_has_gnome_access", lambda self, timeout=None: False + ) + entries = mpr.get_entries_in_chemsys( + "Cu-Se-Si", property_data=["energy_above_hull"] + ) + assert entries + assert not any(e.data["license"] == "BY-NC" for e in entries) + assert all("energy_above_hull" in e.data for e in entries) + + # e.g. with a `db_version` other than the API's, or a missing doc: + search = ThermoRester.search + + def search_missing_one(self, *args, **kwargs): + return search(self, *args, **kwargs)[1:] + + monkeypatch.setattr(ThermoRester, "search", search_missing_one) + with caplog.at_level(logging.WARNING, logger="mp_api.client"): + entries = mpr.get_entries_in_chemsys( + "Cu-Se-Si", property_data=["energy_above_hull"] + ) + undecorated = [e for e in entries if "energy_above_hull" not in e.data] + assert 0 < len(undecorated) < len(entries) + assert "could not be attached" in caplog.text + + def test_get_phase_diagram_without_gnome_matches_remix(self, mpr, monkeypatch): + """Without GNoMe access, the served phase diagram less its GNoMe entries has the + hull that re-mixing the other entries gives (GGA(+U)-only entries placed onto the + r2SCAN hull are re-referenced to it without the GNoMe entries; just dropping + them here leaves Tb-Tc-Ge entries spuriously stable, as of v2026.09.28). + """ + chemsys = "Ge-Tb-Tc" + monkeypatch.setattr( + ThermoRester, "_has_gnome_access", lambda self, timeout=None: False + ) + pd = mpr.materials.thermo.get_phase_diagram_from_chemsys(chemsys) + if not any( + "without GNoMe" in adj.name + for e in pd.all_entries + for adj in e.energy_adjustments + ): + pytest.skip(f"No re-referenced entries in the {chemsys} phase diagram") + + unmixed = mpr._get_unmixed_entries(_all_subchemsyses(chemsys.split("-"))) + remixed = PhaseDiagram( + MaterialsProjectDFTMixingScheme().process_entries( + [e for e in unmixed if e.data.get("license") != "BY-NC"] + ) + ) + + def e_above_hull(phase_diagram): + by_mpid: dict[str, float] = {} + for e in phase_diagram.all_entries: + mpid = str(e.data["material_id"]) + eah = phase_diagram.get_e_above_hull(e) + by_mpid[mpid] = min(by_mpid.get(mpid, eah), eah) + return by_mpid + + served, reference = e_above_hull(pd), e_above_hull(remixed) + assert set(served) == set(reference) + assert all(served[i] == pytest.approx(reference[i], abs=1e-6) for i in served) + @pytest.mark.skipif( contribs_client is None, reason="`pip install 'mp-api[contribs]'` to use pourbaix functionality.", From 78f8814ae2aedd6a0a9fa0e74d07dcc14672fca8 Mon Sep 17 00:00:00 2001 From: Sean Kavanagh Date: Tue, 20 Oct 2026 13:04:20 +0100 Subject: [PATCH 2/3] Assert the GNoMe re-referencing is exercised in the Cu-Se-Si test, so it fails on data drift --- tests/client/materials/test_thermo.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/client/materials/test_thermo.py b/tests/client/materials/test_thermo.py index 117ed7a8..ce367ae0 100644 --- a/tests/client/materials/test_thermo.py +++ b/tests/client/materials/test_thermo.py @@ -98,6 +98,13 @@ def test_get_phase_diagram_from_chemsys_gnome_access(monkeypatch, has_access): # the r2SCAN hull (which are re-referenced to it without the GNoMe entries, so can # only go up), and the hull is rebuilt without the GNoMe entries: assert set(energies) == set(full_energies) - gnome + # Cu-Se-Si is r2SCAN-anchored, so GGA(+U)-only entries are re-referenced (fails on + # data drift, rather than silently skipping the check below): + assert any( + "without GNoMe" in adj.name + for e in pd.all_entries + for adj in e.energy_adjustments + ) for entry in pd.all_entries: i = str(entry.entry_id) if any("without GNoMe" in adj.name for adj in entry.energy_adjustments): From a788d196504a81cd6168bba9ff0243f23b932aa8 Mon Sep 17 00:00:00 2001 From: Sean Kavanagh Date: Sat, 24 Oct 2026 17:46:05 +0100 Subject: [PATCH 3/3] Cleanup --- mp_api/client/core/client.py | 10 ++- mp_api/client/mprester.py | 19 +---- mp_api/client/routes/materials/thermo.py | 88 ++++++++---------------- tests/client/materials/test_thermo.py | 64 +++++++---------- tests/client/test_mprester.py | 35 ++-------- 5 files changed, 66 insertions(+), 150 deletions(-) diff --git a/mp_api/client/core/client.py b/mp_api/client/core/client.py index 434b305a..63a7a00f 100644 --- a/mp_api/client/core/client.py +++ b/mp_api/client/core/client.py @@ -898,12 +898,10 @@ def _check_rest_db_version(self, db_version: str) -> None: ) def _has_gnome_access(self, timeout: int | None = None) -> bool: - """Whether the API key can access the GNoMe (BY-NC licensed) data. + """Whether the API key can access the (BY-NC licensed) GNoMe data. - Data in access-controlled collections that the key cannot access is not - served by the API, so is left out of full dataset downloads and phase - diagrams too. Checked with one request on first use, then cached; a failed - check (e.g. no API key) is treated as no access. + Checked with one request, cached once it succeeds. A failed check (e.g. without + an API key) counts as no access, and is retried on the next call. Args: timeout (int or None) : timeout for the check, defaults to `self.timeout` @@ -933,7 +931,7 @@ def _has_gnome_access(self, timeout: int | None = None) -> bool: logger.debug( f"Could not check access to GNoMe data, assuming none: {exc}" ) - self._gnome_access = False + return False return self._gnome_access def _query_delta_backed( diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py index 9d5ac435..0fab2eca 100644 --- a/mp_api/client/mprester.py +++ b/mp_api/client/mprester.py @@ -62,7 +62,7 @@ DEFAULT_THERMOTYPE_CRITERIA, MAPI_CLIENT_SETTINGS, ) -from mp_api.client.core.utils import LazyImport, _shortlist, load_json, validate_ids +from mp_api.client.core.utils import LazyImport, load_json, validate_ids from mp_api.client.routes import GENERIC_RESTERS from mp_api.client.routes.materials import MATERIALS_RESTERS from mp_api.client.routes.molecules import MOLECULES_RESTERS @@ -1295,9 +1295,7 @@ def get_entries_in_chemsys( Passing ``compatible_only = False`` cannot be served that way and returns entries that are *not* immediately suitable for constructing a phase diagram, with a warning. When MP has no pre-built diagram for the system, the mixing - scheme is re-applied locally (logged as a warning, with a progress bar). For API - keys without access to the GNoMe data, its entries are left out of the served - diagrams (see ``ThermoRester.get_phase_diagram_from_chemsys``). + scheme is re-applied locally (logged as a warning, with a progress bar). Args: elements (str or [str]): Parent chemical system string comprising element @@ -1378,19 +1376,8 @@ def get_entries_in_chemsys( str(doc["material_id"]): {p: doc[p] for p in property_data} for doc in docs } - undecorated = [] for entry in entries: # served entries carry `material_id` - if (mpid := str(entry.data["material_id"])) in props: - entry.data |= props[mpid] - else: # e.g. with a `db_version` other than the API's - undecorated.append(mpid) - if undecorated: - logger.warning( - f"No thermo document was found for {len(undecorated):,} of " - f"the served {'-'.join(sorted(elements_set))} phase diagram " - f"entries ({_shortlist(undecorated)}), so `property_data` " - "could not be attached to them." - ) + entry.data |= props[str(entry.data["material_id"])] if entries is None: # MP has no pre-built diagram for this system, so redo the mixing here as MP does when diff --git a/mp_api/client/routes/materials/thermo.py b/mp_api/client/routes/materials/thermo.py index 81e56803..5f44ee7f 100644 --- a/mp_api/client/routes/materials/thermo.py +++ b/mp_api/client/routes/materials/thermo.py @@ -1,8 +1,6 @@ from __future__ import annotations -import logging from collections import defaultdict -from copy import deepcopy from typing import TYPE_CHECKING import numpy as np @@ -26,53 +24,44 @@ from enums import Enum -logger = logging.getLogger(__name__) - -def _accessible_entries(entries: list, elements: list) -> list: +def _accessible_entries(pd: PhaseDiagram) -> list: """The entries of a pre-built GGA(+U)/r2SCAN phase diagram without the access- controlled (BY-NC licensed, GNoMe) ones, with energies as if mixed without them. - The GNoMe entries are r2SCAN-only, so they do not affect the mixing state or the - other entries' energies, except in r2SCAN-anchored systems: there the mixing scheme - places GGA(+U)-only entries onto the r2SCAN hull (shifting them by the r2SCAN minus - the GGA(+U) hull energy at their composition), which includes the GNoMe entries. - These are re-referenced to the r2SCAN hull without them, as a re-mix would give. + GNoMe entries in the mixed phase diagrams are r2SCAN, so they only affect the mixing + state or other entries' energies in r2SCAN-anchored systems. If this is the case and + GNoMe entries are being pruned, we re-reference to the r2SCAN hull without them, as + a re-mix would give. """ - accessible = [e for e in entries if e.data.get("license") != "BY-NC"] + entries = [e for e in pd.all_entries if e.data.get("license") != "BY-NC"] shifted = [ - i - for i, e in enumerate(accessible) + e + for e in entries if any( "onto the r2SCAN hull" in (a.description or "") for a in e.energy_adjustments ) ] - if not shifted: - return accessible - - r2scan = [ - e for e in entries if str(e.parameters.get("run_type")).lower() == "r2scan" - ] - full_hull = PhaseDiagram(r2scan, elements=elements) - accessible_hull = PhaseDiagram( - [e for e in r2scan if e.data.get("license") != "BY-NC"], elements=elements - ) - for i in shifted: - comp = accessible[i].composition - shift = accessible_hull.get_hull_energy_per_atom(comp) - shift -= full_hull.get_hull_energy_per_atom(comp) - if shift > 1e-10: # (beyond float noise) copy, to leave the served entry as is: - entry = accessible[i] = deepcopy(accessible[i]) - entry.energy_adjustments.append( - ConstantEnergyAdjustment( - shift * comp.num_atoms, - name="MP GGA(+U)/r2SCAN mixing adjustment, without GNoMe entries", - description="Place GGA(+U) energy onto the r2SCAN hull without the " - "access-controlled (GNoMe) entries", + if shifted: + r2scan = [ + e for e in entries if str(e.parameters.get("run_type")).lower() == "r2scan" + ] + hull = PhaseDiagram(r2scan, elements=pd.elements) + shifts = [ # (all before any entry energies change) + hull.get_hull_energy_per_atom(e.composition) + - pd.get_hull_energy_per_atom(e.composition) + for e in shifted + ] + for entry, shift in zip(shifted, shifts, strict=True): + if shift > 1e-10: # beyond float noise + entry.energy_adjustments.append( + ConstantEnergyAdjustment( + shift * entry.composition.num_atoms, + name="MP GGA(+U)/r2SCAN mixing adjustment, without GNoMe entries", + ) ) - ) - return accessible + return entries class ThermoRester(BaseRester): @@ -251,11 +240,6 @@ def get_phase_diagram_from_chemsys( ) -> PhaseDiagram: """Get a pre-computed phase diagram for a given chemsys. - For API keys without access to the GNoMe data, its (BY-NC licensed) entries - are left out, as for all other GNoMe data served to such keys, and the phase - diagram is rebuilt from the remaining entries, with the energies they would - have from the GGA(+U)/r2SCAN mixing scheme without the GNoMe entries. - Arguments: chemsys (str): A chemical system (e.g. Li-Fe-O) thermo_type (ThermoType): The thermo type for the phase diagram. @@ -302,29 +286,13 @@ def get_phase_diagram_from_chemsys( if len(pds := TypeAdapter(list[PhaseDiagramType]).validate_python(as_py)) > 0: pd = pds[0] - # The pre-built phase diagrams include the access-controlled GNoMe entries, which - # (like their thermo docs, needed e.g. for `get_entries_in_chemsys` with - # `property_data`) are otherwise not served to API keys without access to them: + # pre-built phase diagrams include the access-controlled GNoMe entries if ( pd and any(e.data.get("license") == "BY-NC" for e in pd.all_entries) and not self._has_gnome_access() ): - try: - accessible = _accessible_entries(pd.all_entries, pd.elements) - n_left_out = len(pd.all_entries) - len(accessible) - pd = PhaseDiagram(accessible, elements=pd.elements) - except ValueError as exc: # e.g. missing elemental (terminal) entries - logger.warning( - f"Could not rebuild the {sorted_chemsys} phase diagram without its " - f"access-controlled (GNoMe) entries, so these are kept: {exc}" - ) - else: - logger.info( - f"Left out {n_left_out:,} access-controlled (GNoMe) entries from the " - f"{sorted_chemsys} phase diagram, as this API key does not have " - "access to GNoMe data." - ) + pd = PhaseDiagram(_accessible_entries(pd), elements=pd.elements) # Ensure el_ref keys are Element objects for PDPlotter. # Ensure qhull_data is a numpy array diff --git a/tests/client/materials/test_thermo.py b/tests/client/materials/test_thermo.py index ce367ae0..53d9d45b 100644 --- a/tests/client/materials/test_thermo.py +++ b/tests/client/materials/test_thermo.py @@ -71,47 +71,35 @@ def test_get_phase_diagram_from_chemsys(): ) -@pytest.mark.parametrize("has_access", [False, True]) -def test_get_phase_diagram_from_chemsys_gnome_access(monkeypatch, has_access): +def test_get_phase_diagram_from_chemsys_gnome_access(rester): """The (access-controlled, BY-NC licensed) GNoMe entries of the pre-built phase diagrams are only served to API keys with access to the GNoMe data.""" - chemsys = "Cu-Se-Si" # has a GNoMe ground state (Cu3SiSe4) as of v2026.09.28 - monkeypatch.setattr( - ThermoRester, "_has_gnome_access", lambda self, timeout=None: True - ) - full = ThermoRester().get_phase_diagram_from_chemsys(chemsys) + rester._gnome_access = True # (the cached access check) + full = rester.get_phase_diagram_from_chemsys("Cu-Se-Si") + rester._gnome_access = False + pd = rester.get_phase_diagram_from_chemsys("Cu-Se-Si") + + # the GNoMe Cu3SiSe4 is on the r2SCAN-anchored hull (as of v2026.09.28), so the + # GGA(+U)-only entries placed onto it are re-referenced (up) without it, and the + # other entries keep their energies: gnome = {str(e.entry_id) for e in full.all_entries if e.data["license"] == "BY-NC"} - if not gnome: - pytest.skip(f"No GNoMe entries in the served {chemsys} phase diagram") - - monkeypatch.setattr( - ThermoRester, "_has_gnome_access", lambda self, timeout=None: has_access - ) - pd = ThermoRester().get_phase_diagram_from_chemsys(chemsys) - energies = {str(e.entry_id): e.energy_per_atom for e in pd.all_entries} full_energies = {str(e.entry_id): e.energy_per_atom for e in full.all_entries} - if has_access: - assert energies == full_energies - return - - # the other entries keep their energies, except for GGA(+U)-only entries placed onto - # the r2SCAN hull (which are re-referenced to it without the GNoMe entries, so can - # only go up), and the hull is rebuilt without the GNoMe entries: - assert set(energies) == set(full_energies) - gnome - # Cu-Se-Si is r2SCAN-anchored, so GGA(+U)-only entries are re-referenced (fails on - # data drift, rather than silently skipping the check below): - assert any( - "without GNoMe" in adj.name + energies = {str(e.entry_id): e.energy_per_atom for e in pd.all_entries} + reref = { + str(e.entry_id) for e in pd.all_entries - for adj in e.energy_adjustments - ) - for entry in pd.all_entries: - i = str(entry.entry_id) - if any("without GNoMe" in adj.name for adj in entry.energy_adjustments): - assert energies[i] > full_energies[i] - else: - assert energies[i] == pytest.approx(full_energies[i]) - rebuilt = PhaseDiagram(list(pd.all_entries)) - assert {str(e.entry_id) for e in pd.stable_entries} == { - str(e.entry_id) for e in rebuilt.stable_entries + if any("without GNoMe" in adj.name for adj in e.energy_adjustments) } + assert gnome and reref + assert set(energies) == set(full_energies) - gnome + for i, energy in energies.items(): + if i in reref: + assert energy > full_energies[i] + else: + assert energy == pytest.approx(full_energies[i]) + + +def test_failed_gnome_access_check_not_cached(): + with ThermoRester(api_key="a" * 32) as rester: # invalid key, so the check fails + assert not rester._has_gnome_access() + assert rester._gnome_access is None # so checked again on the next call diff --git a/tests/client/test_mprester.py b/tests/client/test_mprester.py index 70b09958..af9bb4d5 100644 --- a/tests/client/test_mprester.py +++ b/tests/client/test_mprester.py @@ -438,11 +438,9 @@ def test_get_entries_in_chemsys_decorated_served_pd(self, mpr): assert entry.energy_per_atom == pytest.approx(energy, abs=1e-8) assert entry.composition.reduced_formula == formula - def test_get_entries_in_chemsys_gnome_access(self, mpr, monkeypatch, caplog): + def test_get_entries_in_chemsys_gnome_access(self, mpr, monkeypatch): """``property_data`` is attached to the served entries also for API keys - without access to the GNoMe data, whose thermo docs these keys cannot get - (previously a ``KeyError`` for any phase diagram with GNoMe entries), and a - served entry without a thermo doc no longer fails the whole query. + without access to the GNoMe data, whose thermo docs these keys cannot get. """ monkeypatch.setattr( ThermoRester, "_has_gnome_access", lambda self, timeout=None: False @@ -454,39 +452,16 @@ def test_get_entries_in_chemsys_gnome_access(self, mpr, monkeypatch, caplog): assert not any(e.data["license"] == "BY-NC" for e in entries) assert all("energy_above_hull" in e.data for e in entries) - # e.g. with a `db_version` other than the API's, or a missing doc: - search = ThermoRester.search - - def search_missing_one(self, *args, **kwargs): - return search(self, *args, **kwargs)[1:] - - monkeypatch.setattr(ThermoRester, "search", search_missing_one) - with caplog.at_level(logging.WARNING, logger="mp_api.client"): - entries = mpr.get_entries_in_chemsys( - "Cu-Se-Si", property_data=["energy_above_hull"] - ) - undecorated = [e for e in entries if "energy_above_hull" not in e.data] - assert 0 < len(undecorated) < len(entries) - assert "could not be attached" in caplog.text - def test_get_phase_diagram_without_gnome_matches_remix(self, mpr, monkeypatch): - """Without GNoMe access, the served phase diagram less its GNoMe entries has the - hull that re-mixing the other entries gives (GGA(+U)-only entries placed onto the - r2SCAN hull are re-referenced to it without the GNoMe entries; just dropping - them here leaves Tb-Tc-Ge entries spuriously stable, as of v2026.09.28). + """Without GNoMe access, the served phase diagram without GNoMe entries has the + hull that re-mixing the other entries gives. """ + # just dropping the GNoMe entries without re-mixing here leaves Tb-Tc-Ge entries spuriously stable chemsys = "Ge-Tb-Tc" monkeypatch.setattr( ThermoRester, "_has_gnome_access", lambda self, timeout=None: False ) pd = mpr.materials.thermo.get_phase_diagram_from_chemsys(chemsys) - if not any( - "without GNoMe" in adj.name - for e in pd.all_entries - for adj in e.energy_adjustments - ): - pytest.skip(f"No re-referenced entries in the {chemsys} phase diagram") - unmixed = mpr._get_unmixed_entries(_all_subchemsyses(chemsys.split("-"))) remixed = PhaseDiagram( MaterialsProjectDFTMixingScheme().process_entries(