Spaces:
Sleeping
Sleeping
| """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." | |
| ), | |
| } | |