Skip to content

Commit 31f1e1f

Browse files
authored
Compositions from weight str (#4183)
* 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
1 parent 613c50f commit 31f1e1f

File tree

2 files changed

+83
-4
lines changed

2 files changed

+83
-4
lines changed

src/pymatgen/core/composition.py

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,8 @@ def contains_element_type(self, category: str) -> bool:
586586

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

589-
def _parse_formula(self, formula: str, strict: bool = True) -> dict[str, float]:
589+
@staticmethod
590+
def _parse_formula(formula: str, strict: bool = True) -> dict[str, float]:
590591
"""
591592
Args:
592593
formula (str): A string formula, e.g. Fe2O3, Li3Fe2(PO4)3.
@@ -678,22 +679,64 @@ def from_dict(cls, dct: dict) -> Self:
678679
return cls(dct)
679680

680681
@classmethod
681-
def from_weight_dict(cls, weight_dict: dict[SpeciesLike, float]) -> Self:
682+
def from_weight_dict(cls, weight_dict: dict[SpeciesLike, float], strict: bool = True, **kwargs) -> Self:
682683
"""Create a Composition based on a dict of atomic fractions calculated
683684
from a dict of weight fractions. Allows for quick creation of the class
684685
from weight-based notations commonly used in the industry, such as
685686
Ti6V4Al and Ni60Ti40.
686687
687688
Args:
688689
weight_dict (dict): {symbol: weight_fraction} dict.
690+
strict (bool): Only allow valid Elements and Species in the Composition. Defaults to True.
691+
**kwargs: Additional kwargs supported by the dict() constructor.
689692
690693
Returns:
691-
Composition
694+
Composition in molar fractions.
695+
696+
Examples:
697+
>>> Composition.from_weights({"Fe": 0.5, "Ni": 0.5})
698+
Composition('Fe0.512434 Ni0.487566')
699+
>>> Composition.from_weights({"Ti": 60, "Ni": 40})
700+
Composition('Ti0.647796 Ni0.352204')
692701
"""
693702
weight_sum = sum(val / Element(el).atomic_mass for el, val in weight_dict.items())
694703
comp_dict = {el: val / Element(el).atomic_mass / weight_sum for el, val in weight_dict.items()}
695704

696-
return cls(comp_dict)
705+
return cls(comp_dict, strict=strict, **kwargs)
706+
707+
@classmethod
708+
def from_weights(cls, *args, strict: bool = True, **kwargs) -> Self:
709+
"""Create a Composition from a weight-based formula.
710+
711+
Args:
712+
*args: Any number of 2-tuples as key-value pairs.
713+
strict (bool): Only allow valid Elements and Species in the Composition. Defaults to False.
714+
allow_negative (bool): Whether to allow negative compositions. Defaults to False.
715+
**kwargs: Additional kwargs supported by the dict() constructor.
716+
717+
Returns:
718+
Composition in molar fractions.
719+
720+
Examples:
721+
>>> Composition.from_weights("Fe50Ti50")
722+
Composition('Fe0.461538 Ti0.538462')
723+
>>> Composition.from_weights({"Fe": 0.5, "Ni": 0.5})
724+
Composition('Fe0.512434 Ni0.487566')
725+
"""
726+
if len(args) == 1 and isinstance(args[0], str):
727+
elem_map: dict[str, float] = cls._parse_formula(args[0])
728+
elif len(args) == 1 and isinstance(args[0], type(cls)):
729+
elem_map = args[0] # type: ignore[assignment]
730+
elif len(args) == 1 and isinstance(args[0], float) and math.isnan(args[0]):
731+
raise ValueError("float('NaN') is not a valid Composition, did you mean 'NaN'?")
732+
else:
733+
elem_map = dict(*args, **kwargs) # type: ignore[assignment]
734+
735+
for val in elem_map.values():
736+
if val < -cls.amount_tolerance:
737+
raise ValueError("Weights in Composition cannot be negative!")
738+
739+
return cls.from_weight_dict(elem_map, strict=strict)
697740

698741
def get_el_amt_dict(self) -> dict[str, float]:
699742
"""

tests/core/test_composition.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,42 @@ def test_to_from_weight_dict(self):
426426
c2 = Composition().from_weight_dict(comp.to_weight_dict)
427427
comp.almost_equals(c2)
428428

429+
def test_composition_from_weights(self):
430+
ref_comp = Composition({"Fe": 0.5, "Ni": 0.5})
431+
432+
# Test basic weight-based composition
433+
comp = Composition.from_weights({"Fe": ref_comp.get_wt_fraction("Fe"), "Ni": ref_comp.get_wt_fraction("Ni")})
434+
assert comp["Fe"] == approx(ref_comp.get_atomic_fraction("Fe"))
435+
assert comp["Ni"] == approx(ref_comp.get_atomic_fraction("Ni"))
436+
437+
# Test with another Composition instance
438+
comp = Composition({"Fe": ref_comp.get_wt_fraction("Fe"), "Ni": ref_comp.get_wt_fraction("Ni")})
439+
comp = Composition.from_weights(comp)
440+
assert comp["Fe"] == approx(ref_comp.get_atomic_fraction("Fe"))
441+
assert comp["Ni"] == approx(ref_comp.get_atomic_fraction("Ni"))
442+
443+
# Test with string input
444+
comp = Composition.from_weights(f"Fe{ref_comp.get_wt_fraction('Fe')}Ni{ref_comp.get_wt_fraction('Ni')}")
445+
assert comp["Fe"] == approx(ref_comp.get_atomic_fraction("Fe"))
446+
assert comp["Ni"] == approx(ref_comp.get_atomic_fraction("Ni"))
447+
448+
# Test with kwargs
449+
comp = Composition.from_weights(Fe=ref_comp.get_wt_fraction("Fe"), Ni=ref_comp.get_wt_fraction("Ni"))
450+
assert comp["Fe"] == approx(ref_comp.get_atomic_fraction("Fe"))
451+
assert comp["Ni"] == approx(ref_comp.get_atomic_fraction("Ni"))
452+
453+
# Test strict mode
454+
with pytest.raises(ValueError, match="'Xx' is not a valid Element"):
455+
Composition.from_weights({"Xx": 10}, strict=True)
456+
457+
# Test allow_negative
458+
with pytest.raises(ValueError, match="Weights in Composition cannot be negative!"):
459+
Composition.from_weights({"Fe": -55.845})
460+
461+
# Test NaN handling
462+
with pytest.raises(ValueError, match=r"float\('NaN'\) is not a valid Composition"):
463+
Composition.from_weights(float("nan"))
464+
429465
def test_as_dict(self):
430466
comp = Composition.from_dict({"Fe": 4, "O": 6})
431467
dct = comp.as_dict()

0 commit comments

Comments
 (0)