-
Notifications
You must be signed in to change notification settings - Fork 26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
SMACT Metallicity Handling Enhancements #367
Changes from 22 commits
17d4ae6
d0e999d
5dbe9d7
e4f4d84
1ff91b8
56246d3
f5ec89a
92aeb6c
db78427
4727c9a
96b4f30
b6dd89a
82b8f6d
9b90f92
30b7acb
ad8fe19
7dcf738
484df32
cfe9e56
7669102
272fd4a
a4980b4
94aec86
fdb2167
7b278f3
ca030ef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
smact.metallicity module | ||
==================== | ||
|
||
.. automodule:: smact.metallicity | ||
:members: | ||
:undoc-members: | ||
:show-inheritance: |
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
"""Utility functions for handling intermetallic compounds in SMACT.""" | ||
|
||
from __future__ import annotations | ||
|
||
import numpy as np | ||
from pymatgen.core import Composition | ||
|
||
import smact | ||
from smact import Element | ||
from smact.properties import valence_electron_count | ||
|
||
|
||
def _ensure_composition(composition: str | Composition) -> Composition: | ||
"""Convert input to a pymatgen Composition if it isn't already. | ||
Args: | ||
composition: Chemical formula as string or pymatgen Composition | ||
Returns: | ||
Composition: A pymatgen Composition object | ||
Raises: | ||
ValueError: If the composition string is empty | ||
ValueError: If the formula is invalid and can't be parsed. | ||
""" | ||
if isinstance(composition, str): | ||
if not composition.strip(): | ||
raise ValueError("Empty composition") | ||
# Try to parse with pymatgen | ||
try: | ||
return Composition(composition) | ||
except ValueError as exc: | ||
# If pymatgen can't parse, re-raise with a message the test expects | ||
raise ValueError("Invalid formula") from exc | ||
return composition | ||
|
||
|
||
def get_element_fraction(composition: str | Composition, element_set: set[str]) -> float: | ||
"""Calculate the fraction of elements from a given set in a composition. | ||
This helper function is used to avoid code duplication in functions that | ||
calculate fractions of specific element types (e.g., metals, d-block elements). | ||
Args: | ||
composition: Chemical formula as string or pymatgen Composition | ||
element_set: Set of element symbols to check for | ||
Returns: | ||
float: Fraction of the composition that consists of elements from the set (0-1) | ||
""" | ||
comp = _ensure_composition(composition) | ||
total_amt = sum(comp.values()) | ||
target_amt = sum(amt for el, amt in comp.items() if el.symbol in element_set) | ||
return target_amt / total_amt | ||
|
||
|
||
def get_metal_fraction(composition: str | Composition) -> float: | ||
"""Calculate the fraction of metallic elements in a composition. | ||
Implemented using get_element_fraction helper with smact.metals set. | ||
""" | ||
return get_element_fraction(composition, smact.metals) | ||
|
||
|
||
def get_d_electron_fraction(composition: str | Composition) -> float: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would suggest renaming this to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Gotcha! I'll fix this in my next commit |
||
"""Calculate the fraction of d-block elements in a composition. | ||
Implemented using get_element_fraction helper with smact.d_block set. | ||
""" | ||
return get_element_fraction(composition, smact.d_block) | ||
|
||
|
||
def get_distinct_metal_count(composition: str | Composition) -> int: | ||
"""Count the number of distinct metallic elements in a composition.""" | ||
comp = _ensure_composition(composition) | ||
return sum(1 for el in comp.elements if el.symbol in smact.metals) | ||
|
||
|
||
def get_pauling_test_mismatch(composition: str | Composition) -> float: | ||
"""Calculate a score for how much the composition deviates from ideal | ||
Pauling electronegativity ordering. | ||
Higher mismatch => more difference (ionic, e.g. NaCl). | ||
Lower mismatch => metal-metal bonds (e.g. Fe-Al). | ||
Returns: | ||
float: Mismatch score (0=perfect match, higher=more deviation, NaN=missing data) | ||
""" | ||
comp = _ensure_composition(composition) | ||
elements = [Element(el.symbol) for el in comp.elements] | ||
electronegativities = [el.pauling_eneg for el in elements] | ||
|
||
# If any element lacks a known electronegativity, return NaN | ||
if None in electronegativities: | ||
return float("nan") | ||
else: | ||
mismatches = [] | ||
for i, (_el1, eneg1) in enumerate(zip(elements, electronegativities, strict=False)): | ||
for _el2, eneg2 in zip(elements[i + 1 :], electronegativities[i + 1 :], strict=False): | ||
# Always use absolute difference | ||
mismatch = abs(eneg1 - eneg2) | ||
mismatches.append(mismatch) | ||
|
||
# Return average mismatch across all unique pairs | ||
return np.mean(mismatches) if mismatches else 0.0 | ||
|
||
|
||
def metallicity_score(composition: str | Composition) -> float: | ||
"""Calculate a score (0-1) indicating the degree of a compound's metallic/alloy nature. | ||
1. Fraction of metallic elements | ||
2. Number of distinct metals | ||
3. d-electron fraction | ||
4. Electronegativity difference (Pauling mismatch) | ||
5. Valence electron count proximity to 8 | ||
Args: | ||
composition: Chemical formula or pymatgen Composition | ||
""" | ||
comp = _ensure_composition(composition) | ||
|
||
# Basic metrics | ||
metal_fraction = get_metal_fraction(comp) | ||
d_electron_fraction = get_d_electron_fraction(comp) | ||
n_metals = get_distinct_metal_count(comp) | ||
|
||
# Valence electron count factor | ||
try: | ||
vec = valence_electron_count(comp.reduced_formula) | ||
vec_factor = 1.0 - abs(vec - 8.0) / 8.0 | ||
except ValueError: | ||
vec_factor = 0.5 | ||
|
||
# Pauling mismatch => large => penalize | ||
pauling_mismatch = get_pauling_test_mismatch(comp) | ||
if np.isnan(pauling_mismatch): | ||
pauling_term = 0.5 | ||
else: | ||
scale = 3.0 | ||
penalty = min(pauling_mismatch / scale, 1.0) | ||
pauling_term = 1.0 - penalty | ||
|
||
# Weighted sum | ||
weights = { | ||
"metal_fraction": 0.3, | ||
"d_electron": 0.2, | ||
"n_metals": 0.2, | ||
"vec": 0.15, | ||
"pauling": 0.15, | ||
} | ||
score = ( | ||
weights["metal_fraction"] * metal_fraction | ||
+ weights["d_electron"] * d_electron_fraction | ||
+ weights["n_metals"] * min(n_metals / 3.0, 1.0) | ||
+ weights["vec"] * vec_factor | ||
+ weights["pauling"] * pauling_term | ||
) | ||
return max(0.0, min(1.0, score)) |
ryannduma marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,172 @@ | ||
"""Tests for the metallicity module.""" | ||
|
||
from __future__ import annotations | ||
|
||
import unittest | ||
|
||
import pytest | ||
from pymatgen.core import Composition | ||
|
||
import smact | ||
from smact.metallicity import ( | ||
get_d_electron_fraction, | ||
get_distinct_metal_count, | ||
get_element_fraction, | ||
get_metal_fraction, | ||
get_pauling_test_mismatch, | ||
metallicity_score, | ||
) | ||
|
||
|
||
class TestMetallicity(unittest.TestCase): | ||
"""Test the metallicity module functionality.""" | ||
|
||
def setUp(self): | ||
"""Set up test cases.""" | ||
# Known metallic/alloy compounds | ||
self.metallic_compounds = [ | ||
"Fe3Al", # Classic intermetallic | ||
"Ni3Ti", # Superalloy component | ||
"Cu3Au", # Ordered alloy | ||
"Fe2Nb", # Laves phase | ||
] | ||
|
||
# Known non-metallic compounds | ||
self.non_metallic_compounds = [ | ||
"NaCl", # Ionic | ||
"SiO2", # Covalent | ||
"Fe2O3", # Metal oxide | ||
"CuSO4", # Complex ionic | ||
] | ||
|
||
def test_get_element_fraction(self): | ||
"""Test the helper function for element fraction calculations.""" | ||
# Test with metals set | ||
self.assertAlmostEqual( | ||
get_element_fraction(Composition("Fe3Al"), smact.metals), | ||
1.0, | ||
places=6, | ||
msg="Expected all elements in Fe3Al to be metals", | ||
) | ||
|
||
# Test with d-block set | ||
self.assertAlmostEqual( | ||
get_element_fraction(Composition("Fe2Nb"), smact.d_block), | ||
1.0, | ||
places=6, | ||
msg="Expected all elements in Fe2Nb to be d-block", | ||
) | ||
|
||
# Test with empty set | ||
self.assertAlmostEqual( | ||
get_element_fraction(Composition("Fe3Al"), set()), | ||
0.0, | ||
places=6, | ||
msg="Expected zero fraction for empty element set", | ||
) | ||
|
||
def test_get_metal_fraction(self): | ||
"""Test metal fraction calculation.""" | ||
# Should be 1.0 for pure metallic compounds | ||
self.assertAlmostEqual( | ||
get_metal_fraction(Composition("Fe3Al")), | ||
1.0, | ||
places=6, | ||
msg="Expected pure metallic composition for Fe3Al", | ||
) | ||
|
||
# Should be 0.0 for compounds with no metals | ||
self.assertAlmostEqual( | ||
get_metal_fraction(Composition("SiO2")), | ||
0.0, | ||
places=6, | ||
msg="Expected no metallic elements in SiO2", | ||
) | ||
|
||
# Should be fractional for mixed compounds | ||
fe2o3 = get_metal_fraction(Composition("Fe2O3")) | ||
self.assertTrue( | ||
0 < fe2o3 < 1, | ||
msg=f"Expected fractional metal content for Fe2O3, got {fe2o3:.2f}", | ||
) | ||
|
||
def test_get_d_electron_fraction(self): | ||
"""Test d-electron fraction calculation.""" | ||
# Should be 1.0 for pure transition metal compounds | ||
self.assertAlmostEqual( | ||
get_d_electron_fraction(Composition("Fe2Nb")), | ||
1.0, | ||
places=6, | ||
msg="Expected all d-block elements in Fe2Nb", | ||
) | ||
|
||
# Should be 0.0 for compounds with no d-block elements | ||
self.assertAlmostEqual( | ||
get_d_electron_fraction(Composition("NaCl")), | ||
0.0, | ||
places=6, | ||
msg="Expected no d-block elements in NaCl", | ||
) | ||
|
||
def test_get_distinct_metal_count(self): | ||
"""Test counting of distinct metals.""" | ||
self.assertEqual( | ||
get_distinct_metal_count(Composition("Fe3Al")), | ||
2, | ||
msg="Expected 2 distinct metals in Fe3Al", | ||
) | ||
self.assertEqual( | ||
get_distinct_metal_count(Composition("NaCl")), | ||
1, | ||
msg="Expected 1 distinct metal in NaCl", | ||
) | ||
self.assertEqual( | ||
get_distinct_metal_count(Composition("SiO2")), | ||
0, | ||
msg="Expected no metals in SiO2", | ||
) | ||
|
||
def test_get_pauling_test_mismatch(self): | ||
"""Test Pauling electronegativity mismatch calculation.""" | ||
# Ionic compounds should have high mismatch | ||
nacl_mismatch = get_pauling_test_mismatch(Composition("NaCl")) | ||
|
||
# Metallic compounds should have lower mismatch | ||
fe3al_mismatch = get_pauling_test_mismatch(Composition("Fe3Al")) | ||
|
||
self.assertTrue( | ||
fe3al_mismatch < nacl_mismatch, | ||
msg=f"Expected lower Pauling mismatch for Fe3Al ({fe3al_mismatch:.2f}) than NaCl ({nacl_mismatch:.2f})", | ||
) | ||
|
||
def test_metallicity_score(self): | ||
"""Test the overall metallicity scoring function.""" | ||
# Known metallic compounds should score high | ||
for formula in self.metallic_compounds: | ||
score = metallicity_score(formula) | ||
self.assertTrue( | ||
score > 0.7, | ||
msg=f"Expected high metallicity score (>0.7) for {formula}, but got {score:.2f}", | ||
) | ||
|
||
# Non-metallic compounds should score low | ||
for formula in self.non_metallic_compounds: | ||
score = metallicity_score(formula) | ||
self.assertTrue( | ||
score < 0.5, | ||
msg=f"Expected low metallicity score (<0.5) for {formula}, but got {score:.2f}", | ||
) | ||
|
||
def test_edge_cases(self): | ||
"""Test edge cases and error handling.""" | ||
# Single element | ||
score = metallicity_score("Fe") | ||
self.assertTrue(0.0 <= score <= 1.0, msg=f"Expected score between 0 and 1 for Fe, got {score:.2f}") | ||
|
||
# Empty composition -> expect ValueError("Empty composition") | ||
with pytest.raises(ValueError, match="Empty composition"): | ||
metallicity_score("") | ||
|
||
# Invalid formula -> e.g. "NotAnElement" | ||
with pytest.raises(ValueError, match="Invalid formula"): | ||
metallicity_score("NotAnElement") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know if this function is needed. Could you explain if it is?
I believe the
Composition
class is already robust enough with its error handling.In each function call in the rest of module, you could probably have
comp=Composition(composition)
even if composition is an instance of the Composition class.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i think this might be a redundancy on my end, but I originally wrote this to ensure the composition was a pymatgen composition object even in contexts where they may enter a composition that's otherwise - ill do some testing on my end and possibly get rid of the function should this be the case and incorporate your suggested changes.