Spaces:
Sleeping
Sleeping
| """Online tools for evaluating an arbitrary uploaded CIF.""" | |
| from __future__ import annotations | |
| import importlib.util | |
| import math | |
| import sys | |
| from functools import lru_cache | |
| from pathlib import Path | |
| from typing import Any | |
| import numpy as np | |
| from pymatgen.core import Structure | |
| from rdkit import Chem, RDConfig | |
| from tools.adsorption import predict_benzene, predict_toluene | |
| from tools.descriptors import compute_scm_eigenvalues | |
| from tools.linker_extraction import extract_linker | |
| from tools.toxicity import predict_toxicity | |
| ROOT = Path(__file__).resolve().parent.parent | |
| COPRINET_ROOT = ROOT / "coprinet" | |
| ACTIVE_TOOLS = [ | |
| "parse_cif", | |
| "predict_adsorption", | |
| "identify_linker", | |
| "check_metals", | |
| "predict_sa_score", | |
| "predict_aquatic_toxicity", | |
| "predict_price", | |
| ] | |
| ALLOWED_METALS = {"Mg", "Al", "Ca", "Ti", "Mn", "Fe", "Cu", "Zn", "Zr", "Ag"} | |
| ALL_METALS = { | |
| "Li", "Be", "Na", "Mg", "Al", "K", "Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", | |
| "Cu", "Zn", "Ga", "Rb", "Sr", "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd", | |
| "In", "Sn", "Cs", "Ba", "La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd", "Tb", "Dy", "Ho", | |
| "Er", "Tm", "Yb", "Lu", "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg", "Tl", "Pb", | |
| "Bi", "Po", "Fr", "Ra", "Ac", "Th", "Pa", "U", "Np", "Pu", | |
| } | |
| def _jsonable(value: Any) -> Any: | |
| if isinstance(value, dict): | |
| return {str(k): _jsonable(v) for k, v in value.items()} | |
| if isinstance(value, list | tuple): | |
| return [_jsonable(v) for v in value] | |
| if isinstance(value, np.ndarray): | |
| return _jsonable(value.tolist()) | |
| if isinstance(value, np.generic): | |
| return _jsonable(value.item()) | |
| if isinstance(value, float) and (math.isnan(value) or math.isinf(value)): | |
| return None | |
| if isinstance(value, Path): | |
| return str(value) | |
| return value | |
| def _error(tool: str, message: str) -> dict[str, Any]: | |
| return {"tool": tool, "status": "error", "summary": message, "error": message} | |
| def _needs_linker(tool: str) -> dict[str, Any]: | |
| return {"tool": tool, "status": "unavailable", "summary": "A linker SMILES must be identified first.", "pass": None} | |
| def parse_cif_tool(cif_path: str) -> dict[str, Any]: | |
| try: | |
| structure = Structure.from_file(cif_path) | |
| elements = sorted({site.specie.symbol for site in structure}) | |
| metals = [element for element in elements if element in ALL_METALS] | |
| return { | |
| "tool": "parse_cif", | |
| "status": "success", | |
| "formula": structure.composition.reduced_formula, | |
| "num_sites": structure.num_sites, | |
| "elements": elements, | |
| "metals": metals, | |
| "summary": f"Parsed {structure.num_sites} atomic sites; metal elements: {', '.join(metals) or 'none'}", | |
| } | |
| except Exception as exc: | |
| return _error("parse_cif", f"CIF parsing failed: {exc}") | |
| def predict_adsorption_tool(cif_path: str) -> dict[str, Any]: | |
| try: | |
| b_desc = compute_scm_eigenvalues(cif_path, 520) | |
| t_desc = compute_scm_eigenvalues(cif_path, 584) | |
| benzene = predict_benzene(b_desc["eigenvalues"]) | |
| toluene = predict_toluene(t_desc["eigenvalues"]) | |
| warnings = [w for w in [b_desc.get("applicability_warning"), t_desc.get("applicability_warning")] if w] | |
| return { | |
| "tool": "predict_adsorption", | |
| "status": "success", | |
| "benzene_uptake_mg_g": benzene["uptake_mg_g"], | |
| "toluene_uptake_mg_g": toluene["uptake_mg_g"], | |
| "benzene_model": benzene["model_version"], | |
| "toluene_model": toluene["model_version"], | |
| "descriptor_raw_dim_benzene": b_desc["raw_dim"], | |
| "descriptor_raw_dim_toluene": t_desc["raw_dim"], | |
| "warnings": warnings, | |
| "summary": f"Benzene {benzene['uptake_mg_g']} mg/g; toluene {toluene['uptake_mg_g']} mg/g", | |
| } | |
| except Exception as exc: | |
| return _error("predict_adsorption", f"Adsorption prediction failed: {exc}") | |
| def identify_linker_tool(cif_path: str) -> dict[str, Any]: | |
| try: | |
| result = extract_linker(cif_path) | |
| status = "success" if result.get("linker_smiles") else "unavailable" | |
| return { | |
| "tool": "identify_linker", | |
| "status": status, | |
| "metals": result.get("metals") or [], | |
| "linker_smiles": result.get("linker_smiles"), | |
| "linker_name": result.get("linker_name"), | |
| "linker_formula": result.get("linker_formula"), | |
| "extraction_level": result.get("extraction_level"), | |
| "extraction_note": result.get("extraction_note"), | |
| "summary": result.get("extraction_note"), | |
| } | |
| except Exception as exc: | |
| return _error("identify_linker", f"Linker identification failed: {exc}") | |
| def check_metals_tool(context: dict[str, Any], cif_path: str) -> dict[str, Any]: | |
| parse = context.get("parse_cif") | |
| if not parse or parse.get("status") == "error": | |
| parse = parse_cif_tool(cif_path) | |
| metals = parse.get("metals") or [] | |
| illegal = [metal for metal in metals if metal not in ALLOWED_METALS] | |
| passed = len(illegal) == 0 | |
| return { | |
| "tool": "check_metals", | |
| "status": "pass" if passed else "fail", | |
| "pass": passed, | |
| "detected_metals": metals, | |
| "illegal_metals": illegal, | |
| "allowed_metals": sorted(ALLOWED_METALS), | |
| "summary": "Metal check passed" if passed else f"Disallowed metals detected: {', '.join(illegal)}", | |
| } | |
| def _sa_module(): | |
| path = Path(RDConfig.RDContribDir) / "SA_Score" / "sascorer.py" | |
| spec = importlib.util.spec_from_file_location("mofscreen_sascorer", path) | |
| if spec is None or spec.loader is None: | |
| raise RuntimeError(f"cannot load sascorer: {path}") | |
| module = importlib.util.module_from_spec(spec) | |
| spec.loader.exec_module(module) | |
| return module | |
| def predict_sa_score_tool(context: dict[str, Any]) -> dict[str, Any]: | |
| smiles = _linker_smiles(context) | |
| if not smiles: | |
| return _needs_linker("predict_sa_score") | |
| mol = Chem.MolFromSmiles(smiles) | |
| if mol is None: | |
| return _error("predict_sa_score", f"Invalid linker SMILES: {smiles}") | |
| try: | |
| score = float(_sa_module().calculateScore(mol)) | |
| return { | |
| "tool": "predict_sa_score", | |
| "status": "success", | |
| "linker_smiles": smiles, | |
| "sa_score": round(score, 3), | |
| "scale": "1 easy, 10 hard", | |
| "easy_synthesis": score <= 6.0, | |
| "summary": f"SA score {score:.3f} (1 easy to synthesize, 10 hard to synthesize)", | |
| } | |
| except Exception as exc: | |
| return _error("predict_sa_score", f"SA score calculation failed: {exc}") | |
| def predict_aquatic_toxicity_tool(context: dict[str, Any]) -> dict[str, Any]: | |
| smiles = _linker_smiles(context) | |
| if not smiles: | |
| return _needs_linker("predict_aquatic_toxicity") | |
| try: | |
| tox = predict_toxicity(smiles) | |
| values = [ | |
| tox.get("IBC50_Vibrio"), | |
| tox.get("IGC50_Tetrahymena"), | |
| tox.get("LC50_Pimephales"), | |
| tox.get("LC50_Daphnia"), | |
| ] | |
| present = [float(v) for v in values if v is not None] | |
| status = "success" if present else "unavailable" | |
| tox.update({ | |
| "tool": "predict_aquatic_toxicity", | |
| "status": status, | |
| "linker_smiles": smiles, | |
| "mean_toxicity": round(sum(present) / len(present), 4) if present else None, | |
| "worst_toxicity": max(present) if present else None, | |
| "summary": "Aquatic toxicity prediction completed" if present else "The toxicity model did not return valid endpoints", | |
| }) | |
| return tox | |
| except Exception as exc: | |
| return _error("predict_aquatic_toxicity", f"Aquatic toxicity prediction failed: {exc}") | |
| def _price_predictor(): | |
| if str(COPRINET_ROOT) not in sys.path: | |
| sys.path.insert(0, str(COPRINET_ROOT)) | |
| import torch | |
| if not getattr(torch.load, "_mofscreen_weights_patch", False): | |
| original_load = torch.load | |
| def load_with_trusted_checkpoint(*args, **kwargs): | |
| # ponytail: local trusted CoPriNet checkpoint; remove when upstream supports torch>=2.6 defaults. | |
| kwargs.setdefault("weights_only", False) | |
| return original_load(*args, **kwargs) | |
| load_with_trusted_checkpoint._mofscreen_weights_patch = True | |
| torch.load = load_with_trusted_checkpoint | |
| from pricePrediction.predict.predict import GraphPricePredictor | |
| return GraphPricePredictor(n_gpus=0, n_cpus=0, batch_size=1) | |
| def predict_price_tool(context: dict[str, Any]) -> dict[str, Any]: | |
| smiles = _linker_smiles(context) | |
| if not smiles: | |
| return _needs_linker("predict_price") | |
| try: | |
| pred = float(next(_price_predictor().yieldPredictions([smiles]))) | |
| mol = Chem.MolFromSmiles(smiles) | |
| mw = Chem.Descriptors.ExactMolWt(mol) if mol is not None else None | |
| usd_per_mmol = math.exp(pred) | |
| usd_per_g = usd_per_mmol * 1000 / mw if mw else None | |
| return { | |
| "tool": "predict_price", | |
| "status": "success", | |
| "linker_smiles": smiles, | |
| "coprinet_log_usd_per_mmol": round(pred, 6), | |
| "usd_per_mmol": round(usd_per_mmol, 6), | |
| "usd_per_g": round(usd_per_g, 6) if usd_per_g is not None else None, | |
| "exact_mol_wt": round(mw, 6) if mw else None, | |
| "summary": f"CoPriNet predicted price {usd_per_mmol:.3f} USD/mmol", | |
| } | |
| except Exception as exc: | |
| return {"tool": "predict_price", "status": "unavailable", "summary": f"CoPriNet price prediction unavailable: {exc}", "pass": None} | |
| def run_online_tools(cif_path: str, requested_tools: list[str]) -> dict[str, Any]: | |
| context: dict[str, Any] = {} | |
| trace: list[dict[str, Any]] = [] | |
| tools = _with_dependencies(requested_tools) | |
| for name in tools: | |
| result = _run_one(name, context, cif_path) | |
| context[name] = _jsonable(result) | |
| trace.append({"agent": "Tool Execution Agent", "action": f"ran {name}", "output": context[name]}) | |
| row = _row(context, Path(cif_path).stem) | |
| return _jsonable({ | |
| "mof_id": Path(cif_path).stem, | |
| "results": context, | |
| "tool_results": context, | |
| "row": row, | |
| "agent_trace": trace, | |
| "gate_status": _gate_status(context), | |
| "recommendation": _recommendation(context), | |
| "final_score": _score(_gate_status(context)), | |
| "explanation": _explanation(context), | |
| "warnings": [], | |
| "errors": [], | |
| }) | |
| def _run_one(name: str, context: dict[str, Any], cif_path: str) -> dict[str, Any]: | |
| if name == "parse_cif": | |
| return parse_cif_tool(cif_path) | |
| if name == "predict_adsorption": | |
| return predict_adsorption_tool(cif_path) | |
| if name == "identify_linker": | |
| return identify_linker_tool(cif_path) | |
| if name == "check_metals": | |
| return check_metals_tool(context, cif_path) | |
| if name == "predict_sa_score": | |
| return predict_sa_score_tool(context) | |
| if name == "predict_aquatic_toxicity": | |
| return predict_aquatic_toxicity_tool(context) | |
| if name == "predict_price": | |
| return predict_price_tool(context) | |
| return _error(name, f"Unknown tool: {name}") | |
| def _with_dependencies(tools: list[str]) -> list[str]: | |
| out: list[str] = [] | |
| for tool in tools: | |
| if tool in {"check_metals"}: | |
| out.append("parse_cif") | |
| if tool in {"predict_sa_score", "predict_aquatic_toxicity", "predict_price"}: | |
| out.append("identify_linker") | |
| out.append(tool) | |
| return list(dict.fromkeys(out)) | |
| def _linker_smiles(context: dict[str, Any]) -> str | None: | |
| linker = context.get("identify_linker") or {} | |
| smiles = linker.get("linker_smiles") | |
| return str(smiles) if smiles else None | |
| def _gate_status(results: dict[str, Any]) -> str: | |
| if any(r.get("status") == "fail" for r in results.values()): | |
| return "failed" | |
| if any(r.get("status") in {"error", "unavailable"} for r in results.values()): | |
| return "incomplete" | |
| return "completed" | |
| def _recommendation(results: dict[str, Any]) -> str: | |
| if any(r.get("status") == "fail" for r in results.values()): | |
| return "not_recommended" | |
| if any(r.get("status") in {"error", "unavailable"} for r in results.values()): | |
| return "needs_manual_review" | |
| return "continue_evaluation" | |
| def _score(status: str) -> float: | |
| return {"completed": 10.0, "incomplete": 5.0, "failed": 0.0}.get(status, 5.0) | |
| def _explanation(results: dict[str, Any]) -> str: | |
| return "; ".join(f"{name}: {payload.get('status')}" for name, payload in results.items()) | |
| def _row(results: dict[str, Any], mof_id: str) -> dict[str, Any]: | |
| adsorption = results.get("predict_adsorption") or {} | |
| metals = results.get("check_metals") or {} | |
| linker = results.get("identify_linker") or {} | |
| sa = results.get("predict_sa_score") or {} | |
| tox = results.get("predict_aquatic_toxicity") or {} | |
| price = results.get("predict_price") or {} | |
| return { | |
| "MOF ID": mof_id, | |
| "Benzene (mg/g)": adsorption.get("benzene_uptake_mg_g"), | |
| "Toluene (mg/g)": adsorption.get("toluene_uptake_mg_g"), | |
| "Metal status": metals.get("status"), | |
| "Metals": ", ".join(metals.get("detected_metals") or []), | |
| "Linker": linker.get("linker_name") or linker.get("linker_formula"), | |
| "Linker SMILES": linker.get("linker_smiles"), | |
| "SA score": sa.get("sa_score"), | |
| "Mean toxicity": tox.get("mean_toxicity"), | |
| "Price USD/g": price.get("usd_per_g"), | |
| "Price USD/mmol": price.get("usd_per_mmol"), | |
| "Gate status": _gate_status(results), | |
| "Recommendation": _recommendation(results), | |
| } | |