Skip to content

Commit 1b429b1

Browse files
authored
Merge branch 'materialsproject:master' into master
2 parents 8bb3a72 + f9d9fe8 commit 1b429b1

File tree

8 files changed

+110
-56
lines changed

8 files changed

+110
-56
lines changed

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,6 @@ namespace_packages = true
297297
no_implicit_optional = false
298298
disable_error_code = ["annotation-unchecked", "override", "operator", "attr-defined", "union-attr", "misc", "call-overload", "index"]
299299
exclude = ['src/pymatgen/analysis', 'src/pymatgen/io/cp2k', 'src/pymatgen/io/lammps']
300-
plugins = ["numpy.typing.mypy_plugin"]
301300

302301
[[tool.mypy.overrides]]
303302
module = ["requests.*", "tabulate.*", "monty.*", "matplotlib.*"]

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,5 +85,5 @@ tzdata==2024.2
8585
# via pandas
8686
uncertainties==3.2.2
8787
# via pymatgen (pyproject.toml)
88-
urllib3==2.2.3
88+
urllib3==2.5.0
8989
# via requests

src/pymatgen/core/periodic_table.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,15 +1088,17 @@ def __repr__(self) -> str:
10881088
return f"Species {self}"
10891089

10901090
def __str__(self) -> str:
1091-
output = self.name if hasattr(self, "name") else self.symbol
1092-
if self.oxi_state is not None:
1093-
abs_charge = formula_double_format(abs(self.oxi_state))
1091+
name = getattr(self, "name", None)
1092+
output = name or self.symbol
1093+
oxi_state = self.oxi_state
1094+
if oxi_state is not None:
1095+
abs_charge = formula_double_format(abs(oxi_state))
10941096
if isinstance(abs_charge, float):
10951097
abs_charge = f"{abs_charge:.2f}" # type: ignore[assignment]
1096-
output += f"{abs_charge}{'+' if self.oxi_state >= 0 else '-'}"
1098+
output += f"{abs_charge}{'+' if oxi_state >= 0 else '-'}"
10971099

1098-
if self._spin is not None:
1099-
spin = self._spin
1100+
spin = self._spin
1101+
if spin is not None:
11001102
output += f",{spin=}"
11011103
return output
11021104

@@ -1484,10 +1486,11 @@ def __repr__(self) -> str:
14841486

14851487
def __str__(self) -> str:
14861488
output = self.symbol
1487-
if self.oxi_state is not None:
1488-
output += f"{formula_double_format(abs(self.oxi_state))}{'+' if self.oxi_state >= 0 else '-'}"
1489-
if self._spin is not None:
1490-
spin = self._spin
1489+
oxi_state = self.oxi_state
1490+
if oxi_state is not None:
1491+
output += f"{formula_double_format(abs(oxi_state))}{'+' if oxi_state >= 0 else '-'}"
1492+
spin = self._spin
1493+
if spin is not None:
14911494
output += f",{spin=}"
14921495
return output
14931496

src/pymatgen/ext/matproj.py

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from __future__ import annotations
1111

12+
import gzip
1213
import itertools
1314
import json
1415
import logging
@@ -19,17 +20,21 @@
1920
from functools import partial
2021
from typing import TYPE_CHECKING, NamedTuple
2122

23+
import numpy as np
2224
import orjson
2325
import requests
2426
from monty.json import MontyDecoder
2527

26-
from pymatgen.core import SETTINGS
28+
from pymatgen.core import SETTINGS, Lattice
2729
from pymatgen.core import __version__ as PMG_VERSION
2830
from pymatgen.core.composition import Composition
31+
from pymatgen.electronic_structure.bandstructure import Kpoint
32+
from pymatgen.phonon import CompletePhononDos, PhononBandStructureSymmLine
2933
from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
3034

3135
if TYPE_CHECKING:
3236
from collections.abc import Callable, Sequence
37+
from typing import Any
3338

3439
from typing_extensions import Self
3540

@@ -474,31 +479,77 @@ def get_entries_in_chemsys(self, elements: str | list[str], *args, **kwargs):
474479

475480
return self.get_entries(criteria, *args, **kwargs)
476481

477-
def get_phonon_bandstructure_by_material_id(self, material_id: str):
482+
def _retrieve_object_from_s3(self, material_id: str, bucket: str, prefix: str, timeout: float = 60) -> Any:
483+
"""
484+
Retrieve data from Amazon S3 OpenData the non-canonical way, using requests.
485+
486+
This should be transitioned to boto3 if long-term support is desired,
487+
or to expand pymatgen support of, e.g., electronic DOS, bandstructure, etc.
488+
489+
Args:
490+
material_id (str): Materials Project material_id
491+
bucket (str): the Materials Project bucket, either materialsproject-parsed
492+
or materialsproject-build
493+
prefix (str) : the prefix of the particular S3 key.
494+
timeout (float = 60) : timeout in seconds for the requests command.
495+
496+
Returns:
497+
json loaded object
498+
"""
499+
response = requests.get(
500+
f"https://s3.us-east-1.amazonaws.com/{bucket}/{prefix}/{material_id}.json.gz",
501+
timeout=timeout,
502+
)
503+
if response.status_code not in {200, 400}:
504+
raise MPRestError(
505+
f"Failed to retrieve data from OpenData with status code {response.status_code}:\n{response.reason}"
506+
)
507+
return orjson.loads(gzip.decompress(response.content))
508+
509+
def get_phonon_bandstructure_by_material_id(self, material_id: str) -> PhononBandStructureSymmLine:
478510
"""Get phonon bandstructure by material_id.
479511
512+
Note that this method borrows constructor methods built into
513+
in the emmet-core model for this data. Calling the `to_pmg`
514+
method of the emmet-core data model handles this.
515+
480516
Args:
481517
material_id (str): Materials Project material_id
482518
483519
Returns:
484520
PhononBandStructureSymmLine: A phonon band structure.
485521
"""
486-
prop = "phonon_bandstructure"
487-
response = self.materials.phonon.search(material_ids=material_id)
488-
return response[0][prop]
522+
data = self._retrieve_object_from_s3(
523+
material_id, bucket="materialsproject-parsed", prefix="ph-bandstructures/dfpt"
524+
)
525+
rlatt = Lattice(data["reciprocal_lattice"])
526+
return PhononBandStructureSymmLine(
527+
[Kpoint(q, lattice=rlatt).frac_coords for q in data["qpoints"]],
528+
np.array(data["frequencies"]),
529+
rlatt,
530+
has_nac=data["has_nac"],
531+
eigendisplacements=np.array(data["eigendisplacements"]),
532+
structure=data["structure"],
533+
labels_dict={k: Kpoint(v, lattice=rlatt).frac_coords for k, v in (data["labels_dict"] or {}).items()},
534+
coords_are_cartesian=False,
535+
)
489536

490-
def get_phonon_dos_by_material_id(self, material_id: str):
537+
def get_phonon_dos_by_material_id(self, material_id: str) -> CompletePhononDos:
491538
"""Get phonon density of states by material_id.
492539
540+
Note that this method borrows constructor methods built into
541+
in the emmet-core model for this data. Calling the `to_pmg`
542+
method of the emmet-core data model handles this.
543+
493544
Args:
494545
material_id (str): Materials Project material_id
495546
496547
Returns:
497548
CompletePhononDos: A phonon DOS object.
498549
"""
499-
prop = "phonon_dos"
500-
response = self.request(f"materials/phonon/?material_ids={material_id}&_fields={prop}")
501-
return response[0][prop]
550+
data = self._retrieve_object_from_s3(material_id, bucket="materialsproject-parsed", prefix="ph-dos/dfpt")
551+
data["pdos"] = data.pop("projected_densities", None)
552+
return CompletePhononDos.from_dict(data)
502553

503554

504555
class MPRestError(Exception):

src/pymatgen/io/cp2k/inputs.py

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,13 @@
4444

4545
if TYPE_CHECKING:
4646
from collections.abc import Iterable, Sequence
47-
from pathlib import Path
4847
from typing import Any, Literal
4948

5049
from typing_extensions import Self
5150

5251
from pymatgen.core.lattice import Lattice
5352
from pymatgen.core.structure import Molecule, Structure
54-
from pymatgen.util.typing import Kpoint
53+
from pymatgen.util.typing import Kpoint, PathLike
5554

5655
__author__ = "Nicholas Winner"
5756
__version__ = "2.0"
@@ -152,7 +151,7 @@ def from_dict(cls, dct: dict) -> Self:
152151
)
153152

154153
@classmethod
155-
def from_str(cls, s: str) -> Self:
154+
def from_str(cls, s: str, description: str | None) -> Self:
156155
"""
157156
Initialize from a string.
158157
@@ -163,12 +162,6 @@ def from_str(cls, s: str) -> Self:
163162
Returns:
164163
Keyword or None
165164
"""
166-
s = s.strip()
167-
if "!" in s or "#" in s:
168-
s, description = re.split("(?:!|#)", s)
169-
description = description.strip()
170-
else:
171-
description = None
172165
units = re.findall(r"\[(.*)\]", s) or [None]
173166
s = re.sub(r"\[(.*)\]", "", s)
174167
args: list[Any] = s.split()
@@ -660,7 +653,7 @@ class Cp2kInput(Section):
660653
title and by implementing the file i/o.
661654
"""
662655

663-
def __init__(self, name: str = "CP2K_INPUT", subsections: dict | None = None, **kwargs):
656+
def __init__(self, name: str = "CP2K_INPUT", subsections: dict | None = None, **kwargs) -> None:
664657
"""Initialize Cp2kInput by calling the super."""
665658
self.name = name
666659
self.subsections = subsections or {}
@@ -675,12 +668,12 @@ def __init__(self, name: str = "CP2K_INPUT", subsections: dict | None = None, **
675668
**kwargs,
676669
)
677670

678-
def get_str(self):
671+
def get_str(self) -> str:
679672
"""Get string representation of the Cp2kInput."""
680673
return "".join(v.get_str() for v in self.subsections.values())
681674

682675
@classmethod
683-
def _from_dict(cls, dct: dict):
676+
def _from_dict(cls, dct: dict) -> Self:
684677
"""Initialize from a dictionary."""
685678
constructor = getattr(
686679
__import__(dct["@module"], globals(), locals(), dct["@class"], 0),
@@ -690,7 +683,7 @@ def _from_dict(cls, dct: dict):
690683
return Cp2kInput("CP2K_INPUT", subsections=constructor.from_dict(dct).subsections)
691684

692685
@classmethod
693-
def from_file(cls, filename: str | Path) -> Self:
686+
def from_file(cls, filename: PathLike) -> Self:
694687
"""Initialize from a file."""
695688
with zopen(filename, mode="rt", encoding="utf-8") as file:
696689
txt = preprocessor(file.read(), os.path.dirname(file.name))
@@ -699,33 +692,38 @@ def from_file(cls, filename: str | Path) -> Self:
699692
@classmethod
700693
def from_str(cls, s: str) -> Self:
701694
"""Initialize from a string."""
702-
lines = s.splitlines()
703-
lines = [line.replace("\t", "") for line in lines]
704-
lines = [line.strip() for line in lines]
705-
lines = [line for line in lines if line]
695+
lines: list[str] = [cleaned for line in s.splitlines() if (cleaned := line.replace("\t", "").strip())]
706696
return cls.from_lines(lines)
707697

708698
@classmethod
709-
def from_lines(cls, lines: list | tuple) -> Self:
699+
def from_lines(cls, lines: Sequence[str]) -> Self:
710700
"""Helper method to read lines of file."""
711701
cp2k_input = Cp2kInput("CP2K_INPUT", subsections={})
712702
Cp2kInput._from_lines(cp2k_input, lines)
713703
return cp2k_input
714704

715-
def _from_lines(self, lines):
705+
def _from_lines(self, lines: Sequence[str]) -> None:
716706
"""Helper method, reads lines of text to get a Cp2kInput."""
717-
current = self.name
718-
description = ""
707+
current: str = self.name
708+
description: str = ""
709+
719710
for line in lines:
720-
if line.startswith(("!", "#")):
721-
description += line[1:].strip()
722-
elif line.upper().startswith("&END"):
711+
line, *comment = re.split(r"[!#]", line, maxsplit=1)
712+
713+
if comment:
714+
description += comment[0].strip()
715+
716+
if not (line := line.strip()):
717+
continue
718+
719+
if line.upper().startswith("&END"):
723720
current = "/".join(current.split("/")[:-1])
721+
724722
elif line.startswith("&"):
725723
name, subsection_params = line.split()[0][1:], line.split()[1:]
726724
subsection_params = (
727725
[]
728-
if len(subsection_params) == 1 and subsection_params[0].upper() in ("T", "TRUE", "F", "FALSE", "ON")
726+
if len(subsection_params) == 1 and subsection_params[0].upper() in {"T", "TRUE", "F", "FALSE", "ON"}
729727
else subsection_params
730728
)
731729
alias = f"{name} {' '.join(subsection_params)}" if subsection_params else None
@@ -745,8 +743,10 @@ def _from_lines(self, lines):
745743
else:
746744
self.by_path(current).insert(sec)
747745
current = f"{current}/{alias or name}"
746+
748747
else:
749-
kwd = Keyword.from_str(line)
748+
kwd = Keyword.from_str(line, (description or None))
749+
750750
if tmp := self.by_path(current).get(kwd.name):
751751
if isinstance(tmp, KeywordList):
752752
self.by_path(current).get(kwd.name).append(kwd)
@@ -761,15 +761,15 @@ def _from_lines(self, lines):
761761

762762
def write_file(
763763
self,
764-
input_filename: str = "cp2k.inp",
765-
output_dir: str = ".",
764+
input_filename: PathLike = "cp2k.inp",
765+
output_dir: PathLike = ".",
766766
make_dir_if_not_present: bool = True,
767-
):
767+
) -> None:
768768
"""Write input to a file.
769769
770770
Args:
771-
input_filename (str, optional): Defaults to "cp2k.inp".
772-
output_dir (str, optional): Defaults to ".".
771+
input_filename (PathLike, optional): Defaults to "cp2k.inp".
772+
output_dir (PathLike, optional): Defaults to ".".
773773
make_dir_if_not_present (bool, optional): Defaults to True.
774774
"""
775775
if not os.path.isdir(output_dir) and make_dir_if_not_present:

tests/ext/test_matproj.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,10 @@ def test_get_entry_by_material_id(mprester):
104104
mprester.get_entry_by_material_id("mp-2022") # "mp-2022" does not exist
105105

106106

107-
@pytest.mark.skip(reason="MP staff broke the API: Jun 2025")
108107
def test_get_phonon_data_by_material_id(mprester):
109108
bs = mprester.get_phonon_bandstructure_by_material_id("mp-661")
110-
print(type(bs))
111109
assert isinstance(bs, PhononBandStructureSymmLine)
112110
dos = mprester.get_phonon_dos_by_material_id("mp-661")
113-
print(type(dos))
114111
assert isinstance(dos, CompletePhononDos)
115112

116113

tests/files/io/cp2k/cp2k.inp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
&FORCE_EVAL
55
METHOD QS
66
STRESS_TENSOR ANALYTICAL
7-
&SUBSYS
7+
&SUBSYS ! "/" used to break input file parser in Section start line
88
! Input parameters needed to set up the CELL.
99
&CELL
1010
A 0.0 2.734364 2.734364

tests/io/cp2k/test_inputs.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,10 +217,14 @@ def test_ci_file(self):
217217
assert isinstance(self.ci["FORCE_EVAL"]["DFT"]["QS"]["EPS_DEFAULT"].values[0], float)
218218

219219
# description retrieval
220+
assert (
221+
self.ci["FORCE_EVAL"]["SUBSYS"].description == '"/" used to break input file parser in Section start line'
222+
)
220223
assert self.ci["FORCE_EVAL"]["SUBSYS"]["CELL"].description == "Input parameters needed to set up the CELL."
221224
assert (
222225
self.ci["FORCE_EVAL"]["DFT"]["MGRID"]["CUTOFF"].description == "Cutoff in [Ry] for finest level of the MG."
223226
)
227+
assert self.ci["FORCE_EVAL"]["METHOD"].description is None
224228

225229
def test_odd_file(self):
226230
scramble = ""

0 commit comments

Comments
 (0)