From 31f1e1fb8cc1bb517d59ea8e484965bb641c42a0 Mon Sep 17 00:00:00 2001 From: Rhys Goodall Date: Mon, 18 Nov 2024 15:41:03 -0500 Subject: [PATCH] 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 --- src/pymatgen/core/composition.py | 51 +++++++++++++++++++++++++++++--- tests/core/test_composition.py | 36 ++++++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/pymatgen/core/composition.py b/src/pymatgen/core/composition.py index 3b9c15f6147..d269c247ff9 100644 --- a/src/pymatgen/core/composition.py +++ b/src/pymatgen/core/composition.py @@ -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. @@ -678,7 +679,7 @@ 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 @@ -686,14 +687,56 @@ def from_weight_dict(cls, weight_dict: dict[SpeciesLike, float]) -> Self: 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]: """ diff --git a/tests/core/test_composition.py b/tests/core/test_composition.py index a926ea4871d..9c00c7678fd 100644 --- a/tests/core/test_composition.py +++ b/tests/core/test_composition.py @@ -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()