mofscreen-agent-api / tools /online_screening.py
andy88836's picture
Deploy MOFScreen-Agent FastAPI backend
4d0da28 verified
Raw
History Blame Contribute Delete
13.9 kB
"""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)}",
}
@lru_cache(maxsize=1)
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}")
@lru_cache(maxsize=1)
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),
}