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
58 changes: 39 additions & 19 deletions mp_api/client/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
48 changes: 48 additions & 0 deletions mp_api/client/routes/materials/thermo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions tests/client/materials/test_thermo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
45 changes: 45 additions & 0 deletions tests/client/test_mprester.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.",
Expand Down
Loading