diff --git a/mp_api/client/core/client.py b/mp_api/client/core/client.py index 3fbf9644..63a7a00f 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,43 @@ 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 (BY-NC licensed) GNoMe data. + + 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` + + 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}" + ) + return False + return self._gnome_access + def _query_delta_backed( self, bucket: str, @@ -995,28 +1033,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/routes/materials/thermo.py b/mp_api/client/routes/materials/thermo.py index cc2101d7..5f44ee7f 100644 --- a/mp_api/client/routes/materials/thermo.py +++ b/mp_api/client/routes/materials/thermo.py @@ -10,6 +10,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 @@ -24,6 +25,45 @@ from enums import Enum +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. + + 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. + """ + entries = [e for e in pd.all_entries if e.data.get("license") != "BY-NC"] + shifted = [ + e + for e in entries + if any( + "onto the r2SCAN hull" in (a.description or "") + for a in e.energy_adjustments + ) + ] + 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 entries + + class ThermoRester(BaseRester): suffix = "materials/thermo" document_model = ThermoDoc # type: ignore @@ -246,6 +286,14 @@ def get_phase_diagram_from_chemsys( if len(pds := TypeAdapter(list[PhaseDiagramType]).validate_python(as_py)) > 0: pd = pds[0] + # 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() + ): + pd = PhaseDiagram(_accessible_entries(pd), elements=pd.elements) + # 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..53d9d45b 100644 --- a/tests/client/materials/test_thermo.py +++ b/tests/client/materials/test_thermo.py @@ -69,3 +69,37 @@ def test_get_phase_diagram_from_chemsys(): pd, PhaseDiagram, ) + + +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.""" + 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"} + full_energies = {str(e.entry_id): e.energy_per_atom for e in full.all_entries} + 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 + 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 1dbb8f04..af9bb4d5 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,49 @@ 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): + """``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. + """ + 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) + + def test_get_phase_diagram_without_gnome_matches_remix(self, mpr, monkeypatch): + """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) + 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.",