Skip to content

Commit

Permalink
Compositions from weight str (#4183)
Browse files Browse the repository at this point in the history
* fea: compositions from weight str

* doc: add examples to show how different weights of Ti and Ni in 5050 alloys result in different compositons.

* doc: add examples to from_weight_dict also
  • Loading branch information
CompRhys authored Nov 18, 2024
1 parent 613c50f commit 31f1e1f
Show file tree
Hide file tree
Showing 2 changed files with 83 additions and 4 deletions.
51 changes: 47 additions & 4 deletions src/pymatgen/core/composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,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 @@ -678,22 +679,64 @@ def from_dict(cls, dct: dict) -> Self:
return cls(dct)

@classmethod
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
Composition in molar fractions.
Examples:
>>> Composition.from_weights({"Fe": 0.5, "Ni": 0.5})
Composition('Fe0.512434 Ni0.487566')
>>> Composition.from_weights({"Ti": 60, "Ni": 40})
Composition('Ti0.647796 Ni0.352204')
"""
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 in molar fractions.
Examples:
>>> Composition.from_weights("Fe50Ti50")
Composition('Fe0.461538 Ti0.538462')
>>> Composition.from_weights({"Fe": 0.5, "Ni": 0.5})
Composition('Fe0.512434 Ni0.487566')
"""
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
36 changes: 36 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

0 comments on commit 31f1e1f

Please sign in to comment.