#!/usr/bin/env python """Helpers for ADMET-guided genetic molecule optimization demos. The public visualization entry is render_optimization_history(...). The module also keeps small validation and scoring helpers that are useful when building a demo pipeline around RDKit, SA-Score, and ADMET-AI. """ from __future__ import annotations import io import json import math import os import re import shutil from collections import Counter from pathlib import Path from typing import Any matplotlib = None pd = None plt = None __all__ = [ "standardize_smiles", "canonicalize_smiles", "classify_admet_columns", "aggregate_admet_predictions", "operation_detail_json", "validate_generation_log", "render_optimization_history", ] _SVG_DECLARATION = re.compile(r"<\?xml[^>]*\?>", re.IGNORECASE) _SVG_DOCTYPE = re.compile(r"]*(?:\[[\s\S]*?\]\s*)?>", re.IGNORECASE) def _require_pandas() -> Any: global pd if pd is None: try: import pandas as pandas_module except ImportError as exc: raise RuntimeError( "pandas is required; switch to the admet-sa-ga environment " "or install the skill prerequisites" ) from exc pd = pandas_module return pd def _require_plotting() -> tuple[Any, Any]: global matplotlib, plt if matplotlib is None or plt is None: os.environ.setdefault("MPLCONFIGDIR", "/tmp/admet-sa-ga-mpl-cache") os.environ.setdefault("XDG_CACHE_HOME", "/tmp/admet-sa-ga-cache") Path(os.environ["MPLCONFIGDIR"]).mkdir(parents=True, exist_ok=True) Path(os.environ["XDG_CACHE_HOME"]).mkdir(parents=True, exist_ok=True) try: import matplotlib as matplotlib_module matplotlib_module.use("Agg") import matplotlib.pyplot as pyplot_module except ImportError as exc: raise RuntimeError( "matplotlib is required to render the optimization dashboard; " "switch to the admet-sa-ga environment or install the skill prerequisites" ) from exc matplotlib = matplotlib_module plt = pyplot_module return matplotlib, plt def standardize_smiles(smiles: str) -> tuple[str | None, str]: """Return (canonical_smiles, failure_reason) after RDKit standardization.""" try: from rdkit import Chem except ImportError as exc: raise RuntimeError( "RDKit is required to standardize SMILES; switch to the " "admet-sa-ga environment or install the skill prerequisites" ) from exc mol = Chem.MolFromSmiles(str(smiles)) if mol is None: return None, "parse_failed" try: Chem.SanitizeMol(mol) except Exception: return None, "sanitize_failed" mol = Chem.RemoveHs(mol) if mol.GetNumAtoms() == 0: return None, "empty_molecule" fragments = Chem.GetMolFrags(mol, asMols=True, sanitizeFrags=True) if not fragments: return None, "multi_fragment_failed" organic = [ frag for frag in fragments if any(atom.GetAtomicNum() == 6 for atom in frag.GetAtoms()) ] mol = max(organic or fragments, key=lambda item: item.GetNumHeavyAtoms()) try: Chem.SanitizeMol(mol) return Chem.MolToSmiles(mol, canonical=True, isomericSmiles=True), "" except Exception: return None, "sanitize_failed" def canonicalize_smiles(smiles: str) -> str: """Return a canonical SMILES when RDKit is installed, else a stripped string.""" value = (smiles or "").strip() if not value: raise ValueError("SMILES is empty") try: from rdkit import Chem # type: ignore except ImportError: return value mol = Chem.MolFromSmiles(value) if mol is None: raise ValueError(f"invalid SMILES: {smiles!r}") return Chem.MolToSmiles(mol, canonical=True) def classify_admet_columns( columns: list[str], admet_config: dict[str, Any] ) -> dict[str, str]: """Map ADMET-AI columns to positive, negative, or ignored score roles.""" positive = [key.lower() for key in admet_config.get("positive_keywords", [])] negative = [key.lower() for key in admet_config.get("negative_keywords", [])] mapping = {} for column in columns: name = column.lower() if "drugbank_approved_percentile" in name: mapping[column] = "ignored" elif any(key in name for key in negative): mapping[column] = "negative" elif any(key in name for key in positive): mapping[column] = "positive" else: mapping[column] = "ignored" return mapping def _numeric_01(value: Any) -> float | None: if value is None: return None if isinstance(value, bool): return 1.0 if value else 0.0 try: number = float(value) except (TypeError, ValueError): text = str(value).strip().lower() if text in {"true", "yes", "positive", "active", "high", "1"}: return 1.0 if text in {"false", "no", "negative", "inactive", "low", "0"}: return 0.0 return None if math.isnan(number): return None if 0.0 <= number <= 1.0: return number return 1.0 / (1.0 + pow(2.718281828459045, -number)) def aggregate_admet_predictions( predictions: dict[str, Any], admet_config: dict[str, Any], ) -> tuple[float, list[str], dict[str, str]]: """Aggregate raw ADMET endpoint predictions into score, risk flags, mapping.""" mapping = classify_admet_columns(list(predictions.keys()), admet_config) scores: list[float] = [] flags: list[str] = [] threshold = float(admet_config.get("risk_threshold", 0.5)) for column, role in mapping.items(): if role == "ignored": continue value = _numeric_01(predictions.get(column)) if value is None: continue if role == "positive": scores.append(value) if value < 1.0 - threshold: flags.append(f"low_{column}") else: scores.append(1.0 - value) if value >= threshold: flags.append(f"high_{column}") score = sum(scores) / len(scores) if scores else 0.5 return score, flags, mapping def operation_detail_json( operation: str, operator_detail: Any, child_canonical_smiles: str, parent_ids: list[str], parent_smiles: list[str], ) -> str: """Build the canonical operation_detail JSON string used in generation logs.""" payload = { "operation": operation, "operator_detail": operator_detail, "parent_ids": parent_ids, "parent_smiles": parent_smiles, "child_canonical_smiles": child_canonical_smiles, } return json.dumps(payload, ensure_ascii=False, sort_keys=True) def validate_generation_log(frame: pd.DataFrame) -> None: """Validate lineage invariants expected by the visualization workflow.""" _require_pandas() required = {"molecule_id", "smiles", "generation", "operation", "parent", "parents"} missing = sorted(required - set(frame.columns)) if missing: raise ValueError(f"generation log missing required columns: {missing}") duplicate_ids = [ key for key, count in Counter(frame["molecule_id"].astype(str)).items() if count > 1 ] duplicate_smiles = [ key for key, count in Counter(frame["smiles"].astype(str)).items() if count > 1 ] if duplicate_ids or duplicate_smiles: raise ValueError( "molecule_id and canonical smiles must be one-to-one; " f"duplicate_ids={duplicate_ids[:5]}, duplicate_smiles={duplicate_smiles[:5]}" ) mutation = frame[frame["operation"].astype(str).str.lower() == "mutation"].fillna( "" ) if len(mutation) and ( (mutation["parent"].astype(str) == "").any() or (mutation["parents"].astype(str) != "").any() ): raise ValueError( "mutation rows must set parent to one ID and leave parents empty" ) crossover = frame[frame["operation"].astype(str).str.lower() == "crossover"].fillna( "" ) if len(crossover): bad_parent = (crossover["parent"].astype(str) != "").any() bad_parents = ( crossover["parents"].astype(str).str.split(";").map(len) != 2 ).any() if bad_parent or bad_parents: raise ValueError( "crossover rows must leave parent empty and set parents to exactly two IDs" ) def _clean_svg(svg: str) -> str: svg = _SVG_DECLARATION.sub("", svg) svg = _SVG_DOCTYPE.sub("", svg) return svg.strip() def _mol_svg(smiles: str, width: int = 150, height: int = 95) -> str: try: from rdkit import Chem from rdkit.Chem import Draw except ImportError: return ( f'' f'' f'structure unavailable' ) mol = Chem.MolFromSmiles(str(smiles)) if mol is None: return "
invalid
" Draw.rdMolDraw2D.PrepareMolForDrawing(mol) drawer = Draw.rdMolDraw2D.MolDraw2DSVG(width, height) drawer.DrawMolecule(mol) drawer.FinishDrawing() return drawer.GetDrawingText().replace("\n", "") def _build_payload(log_path: Path) -> dict: _require_pandas() frame = pd.read_csv(log_path) frame = frame.fillna("") for column in [ "parent", "parents", "parent_id", "parent_ids", "parent_smiles", "source_molecule_id", ]: if column not in frame.columns: frame[column] = "" frame["parent_id"] = frame["parent_id"].where( frame["parent_id"].astype(str) != "", frame["parent"] ) frame["parent_ids"] = frame["parent_ids"].where( frame["parent_ids"].astype(str) != "", frame["parents"] ) validate_generation_log(frame) records = frame.to_dict(orient="records") first_seen = {} first_seen_by_smiles = {} for record in records: first_seen.setdefault(record["molecule_id"], int(record["generation"])) first_seen_by_smiles.setdefault(record["smiles"], int(record["generation"])) record["svg"] = _mol_svg(record["smiles"]) generations = sorted({int(record["generation"]) for record in records}) best_by_gen = [] for gen in generations: subset = frame[frame["generation"] == gen] best_by_gen.append( { "generation": gen, "best": float(subset["total_score"].max()), "mean": float(subset["total_score"].mean()), "total_count": int(len(subset)), "generated_count": int( (subset["operation"].astype(str).str.lower() != "seed").sum() ), "pass_count": int( subset["passes_filters"] .astype(str) .str.lower() .isin(["true", "1"]) .sum() ), } ) return { "records": records, "generations": generations, "best_by_gen": best_by_gen, "first_seen": first_seen, "plots": _build_plots(best_by_gen), "first_seen_by_smiles": first_seen_by_smiles, "meta": { "record_count": int(len(records)), "svg_count": int( len([1 for record in records if record["svg"] is not None]) ), }, } def _setup_plot_style() -> bool: _require_plotting() use_tex = bool(shutil.which("latex") and shutil.which("dvipng")) matplotlib.rcParams.update( { "figure.dpi": 160, "savefig.dpi": 240, "font.family": "serif", "font.serif": ["Times New Roman", "Times", "DejaVu Serif"], "axes.titlesize": 10, "axes.labelsize": 9, "xtick.labelsize": 8, "ytick.labelsize": 8, "legend.fontsize": 8, "axes.linewidth": 0.7, "xtick.major.width": 0.7, "ytick.major.width": 0.7, "xtick.major.size": 3.5, "ytick.major.size": 3.5, "svg.fonttype": "none", "text.usetex": use_tex, } ) if use_tex: matplotlib.rcParams.update( { "text.latex.preamble": r"\usepackage{amsmath}\usepackage{newtxtext}\usepackage{newtxmath}", } ) return use_tex def _figure_to_svg(fig: plt.Figure) -> str: _require_plotting() buffer = io.StringIO() fig.savefig( buffer, format="svg", bbox_inches="tight", pad_inches=0.025, transparent=True ) plt.close(fig) return _clean_svg(buffer.getvalue()) def _make_line_plot( frame: pd.DataFrame, series: list[tuple[str, str, str, str]], title: str, ylabel: str, ) -> str: _require_plotting() fig, ax = plt.subplots(figsize=(6.1, 2.28), constrained_layout=True) fig.patch.set_alpha(0) ax.set_facecolor("none") x = frame["generation"].astype(float) for key, label, color, marker in series: y = frame[key].astype(float) ax.plot( x, y, label=label, color=color, linewidth=2.15, marker=marker, markersize=4.8, markerfacecolor="white", markeredgewidth=1.35, solid_capstyle="round", zorder=3, ) ax.set_title(title, loc="left", pad=7, fontweight="bold", color="#111827") ax.set_xlabel("Generation") ax.set_ylabel(ylabel) ax.grid(axis="y", color="#d9e0e8", linewidth=0.75, alpha=0.75) ax.grid(axis="x", color="#eef2f6", linewidth=0.55, alpha=0.65) ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) ax.spines["left"].set_color("#6b7280") ax.spines["bottom"].set_color("#6b7280") ax.tick_params(colors="#374151", labelcolor="#374151") ax.margins(x=0.035, y=0.14) ax.legend( loc="upper left", bbox_to_anchor=(0.0, 1.02), ncol=min(3, len(series)), frameon=False, handlelength=2.2, columnspacing=1.1, ) return _figure_to_svg(fig) def _build_plots(best_by_gen: list[dict]) -> dict: _require_pandas() _require_plotting() _setup_plot_style() plot_frame = pd.DataFrame(best_by_gen) try: score_plot = _make_line_plot( plot_frame, [ ("best", "Best total score", "#0f766e", "o"), ("mean", "Mean total score", "#4f46e5", "s"), ], r"Optimization score trajectory", "Total score", ) count_plot = _make_line_plot( plot_frame, [ ("generated_count", "Generated", "#2563eb", "o"), ("total_count", "Logged", "#b45309", "D"), ("pass_count", "Passed filters", "#16a34a", "s"), ], r"Generation throughput and filter yield", "Molecules", ) except Exception: matplotlib.rcParams.update({"text.usetex": False}) score_plot = _make_line_plot( plot_frame, [ ("best", "Best total score", "#0f766e", "o"), ("mean", "Mean total score", "#4f46e5", "s"), ], "Optimization score trajectory", "Total score", ) count_plot = _make_line_plot( plot_frame, [ ("generated_count", "Generated", "#2563eb", "o"), ("total_count", "Logged", "#b45309", "D"), ("pass_count", "Passed filters", "#16a34a", "s"), ], "Generation throughput and filter yield", "Molecules", ) return {"score": score_plot, "count": count_plot} _HTML_TEMPLATE = """ Optimization History
GA Optimization History Generation
generations molecules drawings

Population

Selected Molecule

Lineage

parent trace
""" def _render_html(payload: dict) -> str: data = ( json.dumps(payload, ensure_ascii=False) .replace("&", "\\u0026") .replace("<", "\\u003c") .replace(">", "\\u003e") ) return _HTML_TEMPLATE.replace("__DATA__", data) def render_optimization_history(log_path: str | Path, out_path: str | Path) -> Path: """Render a self-contained optimization-history HTML file from generation_log.csv.""" payload = _build_payload(Path(log_path)) out = Path(out_path) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(_render_html(payload), encoding="utf-8") return out