File size: 13,861 Bytes
e5bcce8
 
 
 
 
 
 
 
 
 
 
 
4d0da28
e5bcce8
 
 
 
 
 
 
 
4d0da28
e5bcce8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d0da28
e5bcce8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
"""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),
    }