Skip to content

Commit 17787db

Browse files
committed
Merge remote-tracking branch 'upstream/master' into 4158-fix-eq-check
2 parents 8284fdc + e77a496 commit 17787db

File tree

14 files changed

+264
-58
lines changed

14 files changed

+264
-58
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ See [GitHub releases](https://github.com/materialsproject/pymatgen/releases), [`
7171

7272
## Using pymatgen
7373

74-
Please refer to the official [`pymatgen` docs] for tutorials and examples.
74+
Please refer to the official [`pymatgen` docs] for tutorials and examples. Dr Anubhav Jain (@computron) has also created
75+
a series of [tutorials](https://github.com/computron/pymatgen_tutorials)
76+
and [YouTube videos](https://www.youtube.com/playlist?list=PL7gkuUui8u7_M47KrV4tS4pLwhe7mDAjT), which is a good
77+
resource, especially for beginners.
7578

7679
## How to cite pymatgen
7780

docs/index.md

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/pymatgen/core/composition.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from pymatgen.util.string import Stringify, formula_double_format
2525

2626
if TYPE_CHECKING:
27-
from collections.abc import Generator, Iterator
27+
from collections.abc import Generator, ItemsView, Iterator
2828
from typing import Any, ClassVar, Literal
2929

3030
from typing_extensions import Self
@@ -173,7 +173,7 @@ def __init__(self, *args, strict: bool = False, **kwargs) -> None:
173173
else:
174174
elem_map = dict(*args, **kwargs) # type: ignore[assignment]
175175
elem_amt = {}
176-
self._n_atoms = 0
176+
self._n_atoms: int | float = 0
177177
for key, val in elem_map.items():
178178
if val < -type(self).amount_tolerance and not self.allow_negative:
179179
raise ValueError("Amounts in Composition cannot be negative!")
@@ -184,7 +184,7 @@ def __init__(self, *args, strict: bool = False, **kwargs) -> None:
184184
if strict and not self.valid:
185185
raise ValueError(f"Composition is not valid, contains: {', '.join(map(str, self.elements))}")
186186

187-
def __getitem__(self, key: SpeciesLike) -> float:
187+
def __getitem__(self, key: SpeciesLike) -> int | float:
188188
try:
189189
sp = get_el_sp(key)
190190
if isinstance(sp, Species):
@@ -202,6 +202,10 @@ def __len__(self) -> int:
202202
def __iter__(self) -> Iterator[Species | Element | DummySpecies]:
203203
return iter(self._data)
204204

205+
def items(self) -> ItemsView[Species | Element | DummySpecies, int | float]:
206+
"""Returns Dict.items() for the Composition dict."""
207+
return self._data.items()
208+
205209
def __contains__(self, key) -> bool:
206210
try:
207211
sp = get_el_sp(key)

src/pymatgen/core/trajectory.py

Lines changed: 166 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,30 @@
88
import warnings
99
from fnmatch import fnmatch
1010
from pathlib import Path
11+
from tempfile import NamedTemporaryFile
1112
from typing import TYPE_CHECKING, TypeAlias, cast
1213

1314
import numpy as np
1415
from monty.io import zopen
1516
from monty.json import MSONable
1617

1718
from pymatgen.core.structure import Composition, DummySpecies, Element, Lattice, Molecule, Species, Structure
19+
from pymatgen.io.ase import NO_ASE_ERR, AseAtomsAdaptor
20+
21+
if NO_ASE_ERR is None:
22+
from ase.io.trajectory import Trajectory as AseTrajectory
23+
else:
24+
AseTrajectory = None
25+
1826

1927
if TYPE_CHECKING:
20-
from collections.abc import Iterator
28+
from collections.abc import Iterator, Sequence
2129
from typing import Any
2230

2331
from typing_extensions import Self
2432

2533
from pymatgen.util.typing import Matrix3D, PathLike, SitePropsType, Vector3D
2634

27-
2835
__author__ = "Eric Sivonxay, Shyam Dwaraknath, Mingjian Wen, Evan Spotte-Smith"
2936
__version__ = "0.1"
3037
__date__ = "Jun 29, 2022"
@@ -563,8 +570,6 @@ def from_file(cls, filename: str | Path, constant_lattice: bool = True, **kwargs
563570
Trajectory: containing the structures or molecules in the file.
564571
"""
565572
filename = str(Path(filename).expanduser().resolve())
566-
is_mol = False
567-
molecules = []
568573
structures = []
569574

570575
if fnmatch(filename, "*XDATCAR*"):
@@ -578,31 +583,24 @@ def from_file(cls, filename: str | Path, constant_lattice: bool = True, **kwargs
578583
structures = Vasprun(filename).structures
579584

580585
elif fnmatch(filename, "*.traj"):
581-
try:
582-
from ase.io.trajectory import Trajectory as AseTrajectory
583-
584-
from pymatgen.io.ase import AseAtomsAdaptor
585-
586-
ase_traj = AseTrajectory(filename)
587-
# Periodic boundary conditions should be the same for all frames so just check the first
588-
pbc = ase_traj[0].pbc
586+
if NO_ASE_ERR is None:
587+
return cls.from_ase(
588+
filename,
589+
constant_lattice=constant_lattice,
590+
store_frame_properties=True,
591+
additional_fields=None,
592+
)
593+
raise ImportError("ASE is required to read .traj files. pip install ase")
589594

590-
if any(pbc):
591-
structures = [AseAtomsAdaptor.get_structure(atoms) for atoms in ase_traj]
592-
else:
593-
molecules = [AseAtomsAdaptor.get_molecule(atoms) for atoms in ase_traj]
594-
is_mol = True
595+
elif fnmatch(filename, "*.json*"):
596+
from monty.serialization import loadfn
595597

596-
except ImportError as exc:
597-
raise ImportError("ASE is required to read .traj files. pip install ase") from exc
598+
return loadfn(filename, **kwargs)
598599

599600
else:
600-
supported_file_types = ("XDATCAR", "vasprun.xml", "*.traj")
601+
supported_file_types = ("XDATCAR", "vasprun.xml", "*.traj", ".json")
601602
raise ValueError(f"Expect file to be one of {supported_file_types}; got {filename}.")
602603

603-
if is_mol:
604-
return cls.from_molecules(molecules, **kwargs)
605-
606604
return cls.from_structures(structures, constant_lattice=constant_lattice, **kwargs)
607605

608606
@staticmethod
@@ -734,3 +732,148 @@ def _get_site_props(self, frames: ValidIndex) -> SitePropsType | None:
734732
return [self.site_properties[idx] for idx in frames]
735733
raise ValueError("Unexpected frames type.")
736734
raise ValueError("Unexpected site_properties type.")
735+
736+
@classmethod
737+
def from_ase(
738+
cls,
739+
trajectory: str | Path | AseTrajectory,
740+
constant_lattice: bool | None = None,
741+
store_frame_properties: bool = True,
742+
property_map: dict[str, str] | None = None,
743+
lattice_match_tol: float = 1.0e-6,
744+
additional_fields: Sequence[str] | None = ["temperature", "velocities"],
745+
) -> Trajectory:
746+
"""
747+
Convert an ASE trajectory to a pymatgen trajectory.
748+
749+
Args:
750+
trajectory (str, .Path, or ASE .Trajectory) : the ASE trajectory, or a file path to it if a str or .Path
751+
constant_lattice (bool or None) : if a bool, whether the lattice is constant in the .Trajectory.
752+
If `None`, this is determined on the fly.
753+
store_frame_properties (bool) : Whether to store pymatgen .Trajectory `frame_properties` as
754+
ASE calculator properties. Defaults to True
755+
property_map (dict[str,str]) : A mapping between ASE calculator properties and
756+
pymatgen .Trajectory `frame_properties` keys. Ex.:
757+
property_map = {"energy": "e_0_energy"}
758+
would map `e_0_energy` in the pymatgen .Trajectory `frame_properties`
759+
to ASE's `get_potential_energy` function.
760+
See `ase.calculators.calculator.all_properties` for a list of acceptable calculator properties.
761+
lattice_match_tol (float = 1.0e-6) : tolerance to which lattices are matched if
762+
`constant_lattice = None`.
763+
additional_fields (Sequence of str, defaults to ["temperature", "velocities"]) :
764+
Optional other fields to save in the pymatgen .Trajectory.
765+
Valid options are "temperature" and "velocities".
766+
767+
Returns:
768+
pymatgen .Trajectory
769+
"""
770+
if isinstance(trajectory, str | Path):
771+
trajectory = AseTrajectory(trajectory, "r")
772+
773+
property_map = property_map or {
774+
"energy": "energy",
775+
"forces": "forces",
776+
"stress": "stress",
777+
}
778+
additional_fields = additional_fields or []
779+
780+
adaptor = AseAtomsAdaptor()
781+
782+
structures = []
783+
frame_properties = []
784+
converter = adaptor.get_structure if (is_pbc := any(trajectory[0].pbc)) else adaptor.get_molecule
785+
786+
for atoms in trajectory:
787+
site_properties = {}
788+
if "velocities" in additional_fields:
789+
site_properties["velocities"] = atoms.get_velocities()
790+
791+
structures.append(converter(atoms, site_properties=site_properties))
792+
793+
if store_frame_properties and atoms.calc:
794+
props = {v: atoms.calc.get_property(k) for k, v in property_map.items()}
795+
if "temperature" in additional_fields:
796+
props["temperature"] = atoms.get_temperature()
797+
798+
frame_properties.append(props)
799+
800+
if constant_lattice is None:
801+
constant_lattice = all(
802+
np.all(np.abs(ref_struct.lattice.matrix - structures[j].lattice.matrix)) < lattice_match_tol
803+
for i, ref_struct in enumerate(structures)
804+
for j in range(i + 1, len(structures))
805+
)
806+
807+
if is_pbc:
808+
return cls.from_structures(structures, constant_lattice=constant_lattice, frame_properties=frame_properties)
809+
return cls.from_molecules(
810+
structures,
811+
constant_lattice=constant_lattice,
812+
frame_properties=frame_properties,
813+
)
814+
815+
def to_ase(
816+
self,
817+
property_map: dict[str, str] | None = None,
818+
ase_traj_file: str | Path | None = None,
819+
) -> AseTrajectory:
820+
"""
821+
Convert a pymatgen .Trajectory to an ASE .Trajectory.
822+
823+
Args:
824+
trajectory (pymatgen .Trajectory) : trajectory to convert
825+
property_map (dict[str,str]) : A mapping between ASE calculator properties and
826+
pymatgen .Trajectory `frame_properties` keys. Ex.:
827+
property_map = {"energy": "e_0_energy"}
828+
would map `e_0_energy` in the pymatgen .Trajectory `frame_properties`
829+
to ASE's `get_potential_energy` function.
830+
See `ase.calculators.calculator.all_properties` for a list of acceptable calculator properties.
831+
ase_traj_file (str, Path, or None (default) ) : If not None, the name of
832+
the file to write the ASE trajectory to.
833+
834+
Returns:
835+
ase .Trajectory
836+
"""
837+
if NO_ASE_ERR is not None:
838+
raise ImportError("ASE is required to write .traj files. pip install ase")
839+
840+
from ase.calculators.calculator import all_properties
841+
from ase.calculators.singlepoint import SinglePointCalculator
842+
843+
property_map = property_map or {
844+
"energy": "energy",
845+
"forces": "forces",
846+
"stress": "stress",
847+
}
848+
849+
if (unrecognized_props := set(property_map).difference(set(all_properties))) != set():
850+
raise ValueError(f"Unrecognized ASE calculator properties:\n{', '.join(unrecognized_props)}")
851+
852+
adaptor = AseAtomsAdaptor()
853+
854+
temp_file = None
855+
if ase_traj_file is None:
856+
temp_file = NamedTemporaryFile(delete=False) # noqa: SIM115
857+
ase_traj_file = temp_file.name
858+
859+
frame_props = self.frame_properties or [{} for _ in range(len(self))]
860+
for idx, structure in enumerate(self):
861+
atoms = adaptor.get_atoms(structure, msonable=False, velocities=structure.site_properties.get("velocities"))
862+
863+
props: dict[str, Any] = {k: frame_props[idx][v] for k, v in property_map.items() if v in frame_props[idx]}
864+
865+
# Ensure that `charges` and `magmoms` are not lost from AseAtomsAdaptor
866+
for k in ("charges", "magmoms"):
867+
if k in atoms.calc.implemented_properties or k in atoms.calc.results:
868+
props[k] = atoms.calc.get_property(k)
869+
870+
atoms.calc = SinglePointCalculator(atoms=atoms, **props)
871+
872+
with AseTrajectory(ase_traj_file, "a" if idx > 0 else "w", atoms=atoms) as _traj_file:
873+
_traj_file.write()
874+
875+
ase_traj = AseTrajectory(ase_traj_file, "r")
876+
if temp_file is not None:
877+
temp_file.close()
878+
879+
return ase_traj

src/pymatgen/electronic_structure/dos.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -156,14 +156,13 @@ def get_cbm_vbm(self, tol: float = 1e-4, abs_tol: bool = False, spin: Spin | Non
156156

157157
# Work backwards until tolerance is reached
158158
i_gap_start = i_fermi
159-
while i_gap_start >= 1 and tdos[i_gap_start - 1] <= tol:
159+
while i_gap_start >= 1 and tdos[i_gap_start] <= tol:
160160
i_gap_start -= 1
161161

162162
# Work forwards until tolerance is reached
163-
i_gap_end = i_gap_start
163+
i_gap_end = i_gap_start + 1
164164
while i_gap_end < tdos.shape[0] and tdos[i_gap_end] <= tol:
165165
i_gap_end += 1
166-
i_gap_end -= 1
167166

168167
return self.x[i_gap_end], self.x[i_gap_start]
169168

@@ -378,16 +377,15 @@ def get_cbm_vbm(self, tol: float = 1e-4, abs_tol: bool = False, spin: Spin | Non
378377

379378
# Work backwards until tolerance is reached
380379
i_gap_start = i_fermi
381-
while i_gap_start >= 1 and tdos[i_gap_start - 1] <= tol:
380+
while i_gap_start >= 1 and tdos[i_gap_start] <= tol:
382381
i_gap_start -= 1
383382

384383
# Work forwards until tolerance is reached
385-
i_gap_end = i_gap_start
384+
i_gap_end = i_gap_start + 1
386385
while i_gap_end < tdos.shape[0] and tdos[i_gap_end] <= tol:
387386
i_gap_end += 1
388-
i_gap_end -= 1
389387

390-
return self.energies[i_gap_end], self.energies[i_gap_start]
388+
return (self.energies[min(i_gap_end, len(self.energies) - 1)], self.energies[max(i_gap_start, 0)])
391389

392390
def get_gap(
393391
self,
@@ -518,15 +516,15 @@ def get_doping(self, fermi_level: float, temperature: float) -> float:
518516
(P-type).
519517
"""
520518
cb_integral = np.sum(
521-
self.tdos[self.idx_mid_gap :]
522-
* f0(self.energies[self.idx_mid_gap :], fermi_level, temperature)
523-
* self.de[self.idx_mid_gap :],
519+
self.tdos[max(self.idx_mid_gap, self.idx_vbm + 1) :]
520+
* f0(self.energies[max(self.idx_mid_gap, self.idx_vbm + 1) :], fermi_level, temperature)
521+
* self.de[max(self.idx_mid_gap, self.idx_vbm + 1) :],
524522
axis=0,
525523
)
526524
vb_integral = np.sum(
527-
self.tdos[: self.idx_mid_gap + 1]
528-
* f0(-self.energies[: self.idx_mid_gap + 1], -fermi_level, temperature)
529-
* self.de[: self.idx_mid_gap + 1],
525+
self.tdos[: min(self.idx_mid_gap, self.idx_cbm - 1) + 1]
526+
* f0(-self.energies[: min(self.idx_mid_gap, self.idx_cbm - 1) + 1], -fermi_level, temperature)
527+
* self.de[: min(self.idx_mid_gap, self.idx_cbm - 1) + 1],
530528
axis=0,
531529
)
532530
return (vb_integral - cb_integral) / (self.volume * self.A_to_cm**3)

src/pymatgen/io/aims/inputs.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,10 @@ def get_content(
607607
content += self.get_aims_control_parameter_str(key, output_type, "%s")
608608
elif key == "vdw_correction_hirshfeld" and value:
609609
content += self.get_aims_control_parameter_str(key, "", "%s")
610+
elif key == "xc":
611+
if "libxc" in value:
612+
content += self.get_aims_control_parameter_str("override_warning_libxc", ".true.", "%s")
613+
content += self.get_aims_control_parameter_str(key, value, "%s")
610614
elif isinstance(value, bool):
611615
content += self.get_aims_control_parameter_str(key, str(value).lower(), ".%s.")
612616
elif isinstance(value, tuple | list):

tests/core/test_composition.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,10 @@ def test_init(self):
144144
with pytest.raises(TypeError, match=f"{type(val).__name__!r} object is not iterable"):
145145
Composition(val)
146146

147+
def test_init_mixed_valence(self):
148+
assert Composition({"Fe3+": 2, "Fe2+": 2, "Li": 4, "O": 16, "P": 4}).formula == "Li4 Fe4 P4 O16"
149+
assert Composition({"Fe3+": 2, "Fe": 2, "Li": 4, "O": 16, "P": 4}).formula == "Li4 Fe4 P4 O16"
150+
147151
def test_str_and_repr(self):
148152
test_cases = [
149153
(
@@ -621,8 +625,8 @@ def test_negative_compositions(self):
621625
# test species
622626
c1 = Composition({"Mg": 1, "Mg2+": -1}, allow_negative=True)
623627
assert c1.num_atoms == 2
624-
assert c1.element_composition == Composition("Mg-1", allow_negative=True)
625-
assert c1.average_electroneg == approx(0.655)
628+
assert c1.get_el_amt_dict() == {"Mg": 0}
629+
assert c1.average_electroneg == approx(1.31) # correct Mg electronegativity)
626630

627631
def test_special_formulas(self):
628632
special_formulas = {

0 commit comments

Comments
 (0)