mofscreen-agent-api / tools /linker_extraction.py
andy88836's picture
Deploy MOFScreen-Agent FastAPI backend
bc2a98e verified
Raw
History Blame Contribute Delete
8.44 kB
"""CIF -> metal symbols + linker SMILES extraction with 3-level degradation."""
import math
import re
from pathlib import Path
import yaml
from pymatgen.core import Structure
_METAL_ELEMENTS = {
"Li", "Be", "Na", "Mg", "Al", "K", "Ca", "Sc", "Ti", "V", "Cr", "Mn",
"Fe", "Co", "Ni", "Cu", "Zn", "Ga", "Rb", "Sr", "Y", "Zr", "Nb", "Mo",
"Ru", "Rh", "Pd", "Ag", "Cd", "In", "Sn", "Sb", "Cs", "Ba", "La", "Ce",
"Pr", "Nd", "Sm", "Eu", "Gd", "Tb", "Dy", "Ho", "Er", "Tm", "Yb", "Lu",
"Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg", "Tl", "Pb", "Bi",
"U", "Th",
}
def _load_known_linkers() -> dict:
config_path = Path(__file__).resolve().parent.parent / "configs" / "known_linkers.yaml"
with open(config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
return data.get("linkers", {})
def _extract_metals(structure: Structure) -> list[str]:
metals = set()
for site in structure:
symbol = site.specie.symbol
if symbol in _METAL_ELEMENTS:
metals.add(symbol)
return sorted(metals)
def _get_organic_elements(structure: Structure) -> dict[str, int]:
organic = {}
for site in structure:
symbol = site.specie.symbol
if symbol not in _METAL_ELEMENTS:
organic[symbol] = organic.get(symbol, 0) + 1
return organic
def _format_formula(elements: dict[str, int]) -> str:
parts = []
for el in ["C", "H", "N", "O", "F", "Cl", "Br", "S", "P"]:
if el in elements:
count = elements[el]
parts.append(f"{el}{count}" if count > 1 else el)
for el in sorted(set(elements.keys()) - {"C", "H", "N", "O", "F", "Cl", "Br", "S", "P"}):
count = elements[el]
parts.append(f"{el}{count}" if count > 1 else el)
return "".join(parts)
def _parse_formula(formula: str) -> dict[str, int]:
elements = {}
for m in re.finditer(r"([A-Z][a-z]?)(\d*)", formula):
el, count = m.groups()
if el:
elements[el] = int(count) if count else 1
return elements
def _deprotonate_linker(formula: dict[str, int]) -> list[dict[str, int]]:
"""Generate deprotonated variants by removing 1-6 H atoms (carboxylate deprotonation)."""
variants = [formula.copy()]
if "H" in formula:
for n_remove in range(1, min(7, formula["H"] + 1)):
v = formula.copy()
v["H"] = formula["H"] - n_remove
if v["H"] == 0:
del v["H"]
variants.append(v)
return variants
def _score_linker_match(
linker_elements: dict[str, int],
observed: dict[str, int],
n_metals: int,
) -> tuple[float, int]:
"""Score how well a linker formula matches observed organic composition.
Uses C (or N if no C) as anchor to determine multiplier.
Allows O excess (metal-oxide clusters) and H tolerance (protonation states).
Returns (score, multiplier) where score 0.0=no match, 1.0=perfect.
"""
anchor = "C" if "C" in linker_elements else ("N" if "N" in linker_elements else None)
if anchor is None or anchor not in observed:
return 0.0, 0
multiplier_raw = observed[anchor] / linker_elements[anchor]
multiplier = round(multiplier_raw)
if multiplier < 1 or abs(multiplier_raw - multiplier) > 0.1:
return 0.0, 0
score = 1.0
penalties = 0.0
for el in linker_elements:
expected = linker_elements[el] * multiplier
actual = observed.get(el, 0)
if el == anchor:
if actual != expected:
return 0.0, 0
continue
if el == "O":
if actual < expected:
penalties += 0.3
elif actual > expected:
excess = actual - expected
max_cluster_o = n_metals * 2
if excess <= max_cluster_o:
penalties += 0.05
else:
penalties += 0.4
continue
if el == "H":
tolerance = max(multiplier * 2, 4)
if abs(actual - expected) <= tolerance:
penalties += min(abs(actual - expected) / (expected + 1) * 0.2, 0.2)
else:
penalties += 0.5
continue
if actual != expected:
if abs(actual - expected) / max(expected, 1) < 0.15:
penalties += 0.1
else:
return 0.0, 0
for el in observed:
if el not in linker_elements and el != "O":
return 0.0, 0
final_score = max(0.0, score - penalties)
return final_score, multiplier
def extract_linker(cif_path: str) -> dict:
"""Extract metal nodes and organic linker information from a CIF file.
Three-level degradation:
Level 1: exact formula match (direct or deprotonated variant).
Level 2: ratio-based match with tolerance for cluster atoms.
Level 3: unable_to_assess (metals still reported).
Args:
cif_path: absolute path to a .cif file.
Returns:
{
"metals": list[str],
"linker_smiles": str | None,
"linker_name": str | None,
"linker_formula": str | None,
"extraction_level": int,
"extraction_note": str,
}
Raises:
FileNotFoundError: if cif_path does not exist.
ValueError: if CIF cannot be parsed.
"""
path = Path(cif_path)
if not path.exists():
raise FileNotFoundError(f"CIF file not found: {cif_path}")
structure = Structure.from_file(str(path))
metals = _extract_metals(structure)
observed = _get_organic_elements(structure)
organic_formula = _format_formula(observed)
known_linkers = _load_known_linkers()
if not observed or "C" not in observed:
return {
"metals": metals,
"linker_smiles": None,
"linker_name": None,
"linker_formula": organic_formula if organic_formula else None,
"extraction_level": 3,
"extraction_note": (
f"No organic carbon detected. Formula: {organic_formula}. "
"Adsorption predictions remain valid; toxicity assessment skipped."
),
}
# Level 1: exact formula match (including deprotonated variants)
for key, info in known_linkers.items():
linker_el = _parse_formula(info.get("formula", ""))
for variant in _deprotonate_linker(linker_el):
if variant == observed:
return {
"metals": metals,
"linker_smiles": info["smiles"],
"linker_name": info["name"],
"linker_formula": info["formula"],
"extraction_level": 1,
"extraction_note": f"Exact formula match: '{key}'.",
}
# Level 2: ratio-based match with scoring
n_metals = sum(1 for s in structure if s.specie.symbol in _METAL_ELEMENTS)
best_score = 0.0
best_match = None
best_key = None
best_multiplier = 0
for key, info in known_linkers.items():
linker_el = _parse_formula(info.get("formula", ""))
for variant in _deprotonate_linker(linker_el):
score, mult = _score_linker_match(variant, observed, n_metals)
if score > best_score:
best_score = score
best_match = info
best_key = key
best_multiplier = mult
if best_score >= 0.5 and best_match is not None:
return {
"metals": metals,
"linker_smiles": best_match["smiles"],
"linker_name": best_match["name"],
"linker_formula": best_match["formula"],
"extraction_level": 2,
"extraction_note": (
f"Ratio match to '{best_key}' (score={best_score:.2f}, "
f"~{best_multiplier} linkers/cell). "
f"Organic formula: {organic_formula}."
),
}
# Level 3: unable to assess
return {
"metals": metals,
"linker_smiles": None,
"linker_name": None,
"linker_formula": organic_formula if organic_formula else None,
"extraction_level": 3,
"extraction_note": (
f"Unable to identify linker. Organic formula: {organic_formula}. "
"Adsorption predictions remain valid; toxicity assessment skipped."
),
}