Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Compositions from weight str #4183

Merged
merged 3 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
37 changes: 34 additions & 3 deletions src/pymatgen/core/composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,8 @@ def contains_element_type(self, category: str) -> bool:

return any(getattr(el, f"is_{category}") for el in self.elements)

def _parse_formula(self, formula: str, strict: bool = True) -> dict[str, float]:
@staticmethod
def _parse_formula(formula: str, strict: bool = True) -> dict[str, float]:
"""
Args:
formula (str): A string formula, e.g. Fe2O3, Li3Fe2(PO4)3.
Expand Down Expand Up @@ -639,22 +640,52 @@ def from_dict(cls, dct: dict) -> Self:
return cls(dct)

@classmethod
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think adding a few init examples for this method would be useful. Similar to the one at the init method of Composition.

def from_weight_dict(cls, weight_dict: dict[SpeciesLike, float]) -> Self:
def from_weight_dict(cls, weight_dict: dict[SpeciesLike, float], strict: bool = True, **kwargs) -> Self:
"""Create a Composition based on a dict of atomic fractions calculated
from a dict of weight fractions. Allows for quick creation of the class
from weight-based notations commonly used in the industry, such as
Ti6V4Al and Ni60Ti40.

Args:
weight_dict (dict): {symbol: weight_fraction} dict.
strict (bool): Only allow valid Elements and Species in the Composition. Defaults to True.
**kwargs: Additional kwargs supported by the dict() constructor.

Returns:
Composition
"""
weight_sum = sum(val / Element(el).atomic_mass for el, val in weight_dict.items())
comp_dict = {el: val / Element(el).atomic_mass / weight_sum for el, val in weight_dict.items()}

return cls(comp_dict)
return cls(comp_dict, strict=strict, **kwargs)

@classmethod
def from_weights(cls, *args, strict: bool = True, **kwargs) -> Self:
"""Create a Composition from a weight-based formula.

Args:
*args: Any number of 2-tuples as key-value pairs.
strict (bool): Only allow valid Elements and Species in the Composition. Defaults to False.
allow_negative (bool): Whether to allow negative compositions. Defaults to False.
**kwargs: Additional kwargs supported by the dict() constructor.

Returns:
Composition
"""
if len(args) == 1 and isinstance(args[0], str):
elem_map: dict[str, float] = cls._parse_formula(args[0])
elif len(args) == 1 and isinstance(args[0], type(cls)):
elem_map = args[0] # type: ignore[assignment]
elif len(args) == 1 and isinstance(args[0], float) and math.isnan(args[0]):
raise ValueError("float('NaN') is not a valid Composition, did you mean 'NaN'?")
else:
elem_map = dict(*args, **kwargs) # type: ignore[assignment]

for val in elem_map.values():
if val < -cls.amount_tolerance:
raise ValueError("Weights in Composition cannot be negative!")

return cls.from_weight_dict(elem_map, strict=strict)

def get_el_amt_dict(self) -> dict[str, float]:
"""
Expand Down
42 changes: 42 additions & 0 deletions tests/core/test_composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,42 @@ def test_to_from_weight_dict(self):
c2 = Composition().from_weight_dict(comp.to_weight_dict)
comp.almost_equals(c2)

def test_composition_from_weights(self):
ref_comp = Composition({"Fe": 0.5, "Ni": 0.5})

# Test basic weight-based composition
comp = Composition.from_weights({"Fe": ref_comp.get_wt_fraction("Fe"), "Ni": ref_comp.get_wt_fraction("Ni")})
assert comp["Fe"] == approx(ref_comp.get_atomic_fraction("Fe"))
assert comp["Ni"] == approx(ref_comp.get_atomic_fraction("Ni"))

# Test with another Composition instance
comp = Composition({"Fe": ref_comp.get_wt_fraction("Fe"), "Ni": ref_comp.get_wt_fraction("Ni")})
comp = Composition.from_weights(comp)
assert comp["Fe"] == approx(ref_comp.get_atomic_fraction("Fe"))
assert comp["Ni"] == approx(ref_comp.get_atomic_fraction("Ni"))

# Test with string input
comp = Composition.from_weights(f"Fe{ref_comp.get_wt_fraction('Fe')}Ni{ref_comp.get_wt_fraction('Ni')}")
assert comp["Fe"] == approx(ref_comp.get_atomic_fraction("Fe"))
assert comp["Ni"] == approx(ref_comp.get_atomic_fraction("Ni"))

# Test with kwargs
comp = Composition.from_weights(Fe=ref_comp.get_wt_fraction("Fe"), Ni=ref_comp.get_wt_fraction("Ni"))
assert comp["Fe"] == approx(ref_comp.get_atomic_fraction("Fe"))
assert comp["Ni"] == approx(ref_comp.get_atomic_fraction("Ni"))

# Test strict mode
with pytest.raises(ValueError, match="'Xx' is not a valid Element"):
Composition.from_weights({"Xx": 10}, strict=True)

# Test allow_negative
with pytest.raises(ValueError, match="Weights in Composition cannot be negative!"):
Composition.from_weights({"Fe": -55.845})

# Test NaN handling
with pytest.raises(ValueError, match=r"float\('NaN'\) is not a valid Composition"):
Composition.from_weights(float("nan"))

def test_as_dict(self):
comp = Composition.from_dict({"Fe": 4, "O": 6})
dct = comp.as_dict()
Expand Down Expand Up @@ -867,3 +903,9 @@ def test_square_brackets(self):
# test nested brackets with charge
comp = Composition("[N[Fe]2]2")
assert str(comp) == "N2 Fe4"


if __name__ == "__main__":
import pytest

pytest.main([__file__])
Loading