| """ |
| PoreGCN: Pore-Aware MOF Property Predictor |
| Gradio web interface with per-atom and per-pore XAI, iRASPA CIF export, and |
| 4-tab output layout. |
| |
| Author: Abdulmujeeb T. Onawole |
| Institution: Institute for Molecular Bioscience, The University of Queensland |
| """ |
|
|
| import os |
| import sys |
| import json |
| import warnings |
| import traceback |
| import tempfile |
| from typing import Dict, List, Optional, Tuple, Any |
|
|
| import numpy as np |
| import gradio as gr |
|
|
| warnings.filterwarnings("ignore") |
|
|
| |
| |
| |
|
|
| try: |
| from config import ( |
| DATASETS, |
| DATASET_LABELS, |
| PROPERTY_META, |
| CV_THRESHOLD, |
| AGREEMENT_THRESHOLD, |
| DEVICE, |
| ) |
| from xai_engine import ( |
| load_ensemble, |
| ensemble_predict, |
| compute_attributions, |
| classify_scenario, |
| substructure_breakdown, |
| ) |
| from build_graph import cif_to_graph |
| from visualize import ( |
| create_3d_visualization, |
| export_iraspa_cif, |
| export_attribution_csv, |
| ) |
| BACKEND_AVAILABLE = True |
| except ImportError as _backend_err: |
| BACKEND_AVAILABLE = False |
| _backend_err_msg = str(_backend_err) |
| |
| DATASETS = ["core_mof", "hmof_geometric", "hmof_gas"] |
| DATASET_LABELS = { |
| "core_mof": "CoRE MOF (2,737 MOFs, 7 properties)", |
| "hmof_geometric": "hMOF Geometric (51,163 MOFs, 5 properties)", |
| "hmof_gas": "hMOF Gas (51,163 MOFs, 20 properties)", |
| } |
| PROPERTY_META = {} |
| CV_THRESHOLD = 0.10 |
| AGREEMENT_THRESHOLD = 0.70 |
| DEVICE = "cpu" |
|
|
| |
| |
| |
| |
| |
|
|
| try: |
| from setup_zeopp import ensure_zeopp_binary as _ensure_zeopp |
| _zeopp_ok = _ensure_zeopp() |
| if _zeopp_ok: |
| print("Zeo++ binary ready. Pore-node mode enabled.") |
| else: |
| print("Zeo++ binary not available. Running in atom-only mode (pore nodes disabled).") |
| except Exception as _zeopp_setup_err: |
| print(f"Zeo++ setup error (non-fatal): {_zeopp_setup_err}") |
|
|
| |
| |
| |
|
|
| HERE = os.path.dirname(os.path.abspath(__file__)) |
| EXAMPLES_DIR = os.path.join(HERE, "example_cifs") |
| MODELS_DIR = os.path.join(HERE, "models") |
|
|
| |
| |
| |
|
|
| print("=" * 60) |
| print("PoreGCN MOF Predictor — Loading ensembles...") |
| print("=" * 60) |
|
|
| _ENSEMBLES: Dict[str, Any] = {} |
|
|
| if BACKEND_AVAILABLE: |
| import json as _json |
| from pathlib import Path as _Path |
| HERE_PATH = _Path(__file__).parent |
| for _ds in DATASETS: |
| try: |
| _models, _best, _norm, _props = load_ensemble(_ds, DEVICE) |
| |
| |
| |
| |
| |
| _means_path = HERE_PATH / "models" / f"{_ds}_ensemble" / "property_means.json" |
| _prop_means: Dict[str, float] = {} |
| if _means_path.exists(): |
| try: |
| _prop_means = _json.loads(_means_path.read_text()) |
| except Exception as _me: |
| print(f" [{_ds}] property_means.json parse error: {_me}") |
| else: |
| print(f" [{_ds}] WARNING: property_means.json missing") |
| _ENSEMBLES[_ds] = { |
| "models": _models, |
| "best_model": _best, |
| "normalizer": _norm, |
| "prop_names": _props, |
| "property_means": _prop_means, |
| } |
| print(f" [{_ds}] Loaded {len(_models)} models, {len(_props)} properties, " |
| f"means for {len(_prop_means)} props") |
| except Exception as _e: |
| print(f" [{_ds}] Failed: {_e}") |
| _ENSEMBLES[_ds] = None |
| else: |
| print(f" Backend unavailable: {_backend_err_msg}") |
|
|
| print("=" * 60) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| CUSTOM_CSS = """ |
| @import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,500;0,600;1,400&display=swap'); |
| |
| /* ---- Root tokens ---- */ |
| :root { |
| --slate: #0f172a; |
| --slate-m: #1e293b; |
| --stone: #334155; |
| --mist: #94a3b8; |
| --edge: #e2e8f0; |
| --bg: #f8fafc; |
| --white: #ffffff; |
| --teal: #0891b2; |
| --teal-d: #0e7490; |
| --teal-lt: #e0f2fe; |
| --green: #16a34a; |
| --green-lt: #dcfce7; |
| --amber: #d97706; |
| --amber-lt: #fef3c7; |
| --blue: #2563eb; |
| --blue-lt: #dbeafe; |
| --red: #dc2626; |
| --red-lt: #fee2e2; |
| --mono: 'IBM Plex Mono', 'JetBrains Mono', 'Fira Mono', monospace; |
| --sans: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, sans-serif; |
| --display: 'Syne', 'IBM Plex Sans', sans-serif; |
| } |
| |
| /* ---- Container ---- */ |
| .gradio-container { |
| max-width: 1440px !important; |
| margin: 0 auto !important; |
| font-family: var(--sans) !important; |
| background: var(--bg) !important; |
| } |
| |
| /* Graph-paper background texture on body */ |
| body { |
| background-color: var(--bg) !important; |
| background-image: |
| linear-gradient(rgba(8,145,178,0.04) 1px, transparent 1px), |
| linear-gradient(90deg, rgba(8,145,178,0.04) 1px, transparent 1px) !important; |
| background-size: 28px 28px !important; |
| } |
| |
| /* ---- Header ---- */ |
| .poregcn-header { |
| background: var(--slate) !important; |
| padding: 40px 44px 32px !important; |
| border-radius: 4px !important; |
| margin-bottom: 20px !important; |
| position: relative; |
| overflow: hidden; |
| border-left: 4px solid var(--teal) !important; |
| } |
| .poregcn-header::after { |
| content: 'PORE'; |
| position: absolute; |
| right: -12px; |
| top: -12px; |
| font-family: var(--display); |
| font-size: 9em; |
| font-weight: 800; |
| color: rgba(8,145,178,0.06); |
| letter-spacing: -0.05em; |
| pointer-events: none; |
| line-height: 1; |
| user-select: none; |
| } |
| .poregcn-header h1 { |
| font-family: var(--display) !important; |
| font-size: 2.1em !important; |
| font-weight: 800 !important; |
| color: #ffffff !important; |
| margin: 0 0 6px 0 !important; |
| letter-spacing: -0.03em !important; |
| line-height: 1.1 !important; |
| } |
| .poregcn-header h1 span { |
| color: var(--teal) !important; |
| font-weight: 800 !important; |
| } |
| .poregcn-header .subtitle { |
| font-family: var(--sans) !important; |
| color: #94a3b8 !important; |
| font-size: 0.94em !important; |
| line-height: 1.65 !important; |
| margin: 0 0 20px 0 !important; |
| max-width: 640px !important; |
| font-weight: 300 !important; |
| } |
| .header-badges { |
| display: flex; |
| gap: 8px; |
| flex-wrap: wrap; |
| } |
| .hbadge { |
| font-family: var(--mono) !important; |
| font-size: 0.70em !important; |
| font-weight: 500 !important; |
| padding: 3px 10px !important; |
| border: 1px solid rgba(148,163,184,0.25) !important; |
| color: #94a3b8 !important; |
| letter-spacing: 0.04em !important; |
| border-radius: 2px !important; |
| background: rgba(255,255,255,0.04) !important; |
| } |
| .hbadge-accent { |
| border-color: rgba(8,145,178,0.4) !important; |
| color: #67e8f9 !important; |
| background: rgba(8,145,178,0.08) !important; |
| } |
| |
| /* ---- Input panel ---- */ |
| .input-panel { |
| background: var(--white) !important; |
| border: 1px solid var(--edge) !important; |
| border-radius: 4px !important; |
| padding: 24px !important; |
| } |
| .panel-label { |
| font-family: var(--display) !important; |
| font-size: 0.70em !important; |
| font-weight: 700 !important; |
| text-transform: uppercase !important; |
| letter-spacing: 0.12em !important; |
| color: var(--mist) !important; |
| margin: 0 0 14px 0 !important; |
| display: block !important; |
| border-bottom: 1px solid var(--edge) !important; |
| padding-bottom: 8px !important; |
| } |
| |
| /* Run button */ |
| button.primary-btn, .run-btn button { |
| background: var(--slate) !important; |
| color: #ffffff !important; |
| border: none !important; |
| border-radius: 2px !important; |
| font-family: var(--display) !important; |
| font-weight: 700 !important; |
| font-size: 0.95em !important; |
| letter-spacing: 0.04em !important; |
| padding: 14px 28px !important; |
| transition: background 0.15s ease !important; |
| cursor: pointer !important; |
| } |
| button.primary-btn:hover, .run-btn button:hover { |
| background: var(--teal-d) !important; |
| } |
| |
| /* Status textbox */ |
| .status-box textarea { |
| font-family: var(--mono) !important; |
| font-size: 0.80em !important; |
| color: var(--stone) !important; |
| background: #f1f5f9 !important; |
| border: 1px solid var(--edge) !important; |
| border-radius: 2px !important; |
| } |
| |
| /* ---- Scenario pills ---- */ |
| .scenario-pill { |
| display: inline-flex; |
| align-items: center; |
| gap: 5px; |
| padding: 3px 10px; |
| border-radius: 2px; |
| font-family: var(--mono); |
| font-size: 0.72em; |
| font-weight: 600; |
| letter-spacing: 0.06em; |
| border: 1px solid; |
| } |
| .scenario-A { background: var(--green-lt); color: var(--green); border-color: #86efac; } |
| .scenario-B { background: var(--amber-lt); color: var(--amber); border-color: #fcd34d; } |
| .scenario-C { background: var(--blue-lt); color: var(--blue); border-color: #93c5fd; } |
| .scenario-D { background: var(--red-lt); color: var(--red); border-color: #fca5a5; } |
| |
| /* ---- Predictions table ---- */ |
| .pred-table { |
| width: 100%; |
| border-collapse: separate; |
| border-spacing: 0; |
| font-size: 0.875em; |
| border: 1px solid var(--edge); |
| border-radius: 4px; |
| overflow: hidden; |
| } |
| .pred-table thead th { |
| background: var(--slate-m) !important; |
| color: #cbd5e1 !important; |
| padding: 10px 14px; |
| text-align: left; |
| font-family: var(--display); |
| font-weight: 600; |
| font-size: 0.78em; |
| letter-spacing: 0.08em; |
| text-transform: uppercase; |
| border-bottom: 2px solid var(--teal); |
| } |
| .pred-table thead th:nth-child(2) { text-align: right; } |
| .pred-table thead th:nth-child(3) { text-align: center; } |
| .pred-table thead th:nth-child(4) { text-align: center; } |
| .pred-table tbody td { |
| padding: 9px 14px; |
| border-bottom: 1px solid var(--edge); |
| color: var(--stone); |
| vertical-align: middle; |
| } |
| .pred-table tbody tr:last-child td { border-bottom: none; } |
| .pred-table tbody tr:hover { background: #f1f5f9; } |
| .pred-table .prop-name { |
| font-family: var(--sans); |
| font-weight: 500; |
| color: var(--slate); |
| } |
| .pred-table .prop-desc { |
| font-family: var(--sans); |
| font-size: 0.82em; |
| color: var(--mist); |
| display: block; |
| margin-top: 1px; |
| } |
| .pred-table .pred-val { |
| font-family: var(--mono); |
| font-weight: 500; |
| color: var(--slate); |
| text-align: right; |
| white-space: nowrap; |
| } |
| .pred-table .pred-std { |
| font-family: var(--mono); |
| font-size: 0.82em; |
| color: var(--mist); |
| display: block; |
| text-align: right; |
| } |
| .pred-table .cv-val { |
| font-family: var(--mono); |
| font-size: 0.85em; |
| text-align: center; |
| } |
| .cv-low { color: var(--green); } |
| .cv-mid { color: var(--amber); } |
| .cv-high { color: var(--red); } |
| .pred-table td.scenario-cell { text-align: center; } |
| |
| /* ---- Summary bar ---- */ |
| .summary-bar { |
| background: var(--slate); |
| color: #e2e8f0; |
| border-radius: 2px; |
| padding: 12px 18px; |
| font-family: var(--mono); |
| font-size: 0.82em; |
| margin-top: 14px; |
| line-height: 1.6; |
| border-left: 3px solid var(--teal); |
| } |
| .summary-bar strong { color: #67e8f9; } |
| |
| /* ---- Welcome placeholder ---- */ |
| .welcome-state { |
| border: 2px dashed var(--edge); |
| border-radius: 4px; |
| padding: 64px 40px; |
| text-align: center; |
| background: var(--white); |
| } |
| .welcome-state .welcome-title { |
| font-family: var(--display); |
| font-size: 1.15em; |
| font-weight: 700; |
| color: #64748b; |
| margin: 0 0 8px 0; |
| } |
| .welcome-state p { |
| font-family: var(--sans); |
| color: var(--mist); |
| font-size: 0.92em; |
| margin: 0; |
| } |
| |
| /* ---- Error / warning cards ---- */ |
| .error-card { |
| border: 1px solid var(--red); |
| border-left: 4px solid var(--red); |
| background: var(--red-lt); |
| border-radius: 3px; |
| padding: 16px 20px; |
| color: #991b1b; |
| font-family: var(--sans); |
| font-size: 0.88em; |
| line-height: 1.6; |
| } |
| .error-card .err-title { |
| font-family: var(--display); |
| font-weight: 700; |
| font-size: 1.0em; |
| margin-bottom: 6px; |
| } |
| .warning-card { |
| border: 1px solid var(--amber); |
| border-left: 4px solid var(--amber); |
| background: var(--amber-lt); |
| border-radius: 3px; |
| padding: 12px 18px; |
| color: #92400e; |
| font-family: var(--sans); |
| font-size: 0.85em; |
| margin-bottom: 14px; |
| } |
| |
| /* ---- 3D viewer info panel ---- */ |
| .viewer-info-panel { |
| background: var(--slate-m); |
| color: #94a3b8; |
| border-radius: 3px; |
| padding: 10px 16px; |
| font-family: var(--mono); |
| font-size: 0.78em; |
| line-height: 1.7; |
| margin-bottom: 12px; |
| border-left: 3px solid var(--teal); |
| } |
| .viewer-info-panel span.highlight { color: #67e8f9; font-weight: 600; } |
| |
| .top-atoms-bar { |
| background: var(--white); |
| border: 1px solid var(--edge); |
| border-radius: 3px; |
| padding: 12px 16px; |
| margin-top: 12px; |
| font-family: var(--mono); |
| font-size: 0.80em; |
| color: var(--stone); |
| line-height: 1.8; |
| } |
| .top-atoms-bar .ta-label { |
| font-family: var(--display); |
| font-size: 0.72em; |
| text-transform: uppercase; |
| letter-spacing: 0.1em; |
| color: var(--mist); |
| display: block; |
| margin-bottom: 6px; |
| } |
| .atom-pos { color: var(--green); font-weight: 500; } |
| .atom-neg { color: var(--red); font-weight: 500; } |
| |
| /* ---- Scenario legend grid ---- */ |
| .scenario-grid { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 10px; |
| margin-bottom: 18px; |
| } |
| .scenario-cell { |
| border-radius: 3px; |
| padding: 16px 18px; |
| border: 1px solid; |
| } |
| .sc-A { background: var(--green-lt); border-color: #86efac; } |
| .sc-B { background: var(--amber-lt); border-color: #fcd34d; } |
| .sc-C { background: var(--blue-lt); border-color: #93c5fd; } |
| .sc-D { background: var(--red-lt); border-color: #fca5a5; } |
| .sc-letter { |
| font-family: var(--display); |
| font-size: 1.8em; |
| font-weight: 800; |
| line-height: 1; |
| margin-bottom: 4px; |
| } |
| .sc-A .sc-letter { color: var(--green); } |
| .sc-B .sc-letter { color: var(--amber); } |
| .sc-C .sc-letter { color: var(--blue); } |
| .sc-D .sc-letter { color: var(--red); } |
| .sc-title { |
| font-family: var(--display); |
| font-weight: 700; |
| font-size: 0.88em; |
| margin-bottom: 4px; |
| } |
| .sc-desc { |
| font-family: var(--sans); |
| font-size: 0.80em; |
| color: var(--stone); |
| line-height: 1.5; |
| } |
| .sc-criteria { |
| font-family: var(--mono); |
| font-size: 0.74em; |
| margin-top: 6px; |
| opacity: 0.75; |
| } |
| |
| /* ---- Substructure breakdown bars ---- */ |
| .breakdown-section { |
| background: var(--white); |
| border: 1px solid var(--edge); |
| border-radius: 3px; |
| padding: 18px 20px; |
| } |
| .breakdown-title { |
| font-family: var(--display); |
| font-size: 0.75em; |
| font-weight: 700; |
| text-transform: uppercase; |
| letter-spacing: 0.10em; |
| color: var(--mist); |
| margin: 0 0 14px 0; |
| padding-bottom: 8px; |
| border-bottom: 1px solid var(--edge); |
| } |
| .breakdown-row { |
| margin-bottom: 14px; |
| } |
| .breakdown-row:last-child { margin-bottom: 0; } |
| .breakdown-label { |
| display: flex; |
| justify-content: space-between; |
| align-items: baseline; |
| margin-bottom: 5px; |
| } |
| .breakdown-name { |
| font-family: var(--sans); |
| font-weight: 500; |
| font-size: 0.88em; |
| color: var(--slate); |
| } |
| .breakdown-pct { |
| font-family: var(--mono); |
| font-weight: 600; |
| font-size: 0.85em; |
| color: var(--stone); |
| } |
| .breakdown-track { |
| height: 8px; |
| background: var(--edge); |
| border-radius: 4px; |
| overflow: hidden; |
| } |
| .breakdown-fill { |
| height: 100%; |
| border-radius: 4px; |
| transition: width 0.4s ease; |
| } |
| .fill-metal { background: #f87171; } |
| .fill-linker { background: #60a5fa; } |
| .fill-pore { background: var(--teal); } |
| |
| /* ---- Download tab ---- */ |
| .iraspa-instructions { |
| background: var(--white); |
| border: 1px solid var(--edge); |
| border-radius: 3px; |
| padding: 20px 24px; |
| font-family: var(--sans); |
| font-size: 0.88em; |
| line-height: 1.75; |
| color: var(--stone); |
| } |
| .iraspa-instructions .step-label { |
| font-family: var(--display); |
| font-weight: 700; |
| color: var(--slate); |
| font-size: 0.88em; |
| display: block; |
| margin-top: 14px; |
| margin-bottom: 2px; |
| } |
| .iraspa-instructions code { |
| font-family: var(--mono); |
| background: #f1f5f9; |
| padding: 1px 6px; |
| border-radius: 2px; |
| font-size: 0.92em; |
| color: var(--teal-d); |
| } |
| |
| /* ---- Footer ---- */ |
| .poregcn-footer { |
| text-align: center; |
| font-family: var(--sans); |
| font-size: 0.76em; |
| color: var(--mist); |
| margin-top: 12px; |
| padding: 14px 0 8px; |
| border-top: 1px solid var(--edge); |
| line-height: 1.8; |
| } |
| .poregcn-footer a { |
| color: var(--teal); |
| text-decoration: none; |
| } |
| .poregcn-footer a:hover { text-decoration: underline; } |
| |
| /* ---- Responsive ---- */ |
| @media (max-width: 900px) { |
| .poregcn-header { padding: 24px 20px 20px !important; } |
| .poregcn-header h1 { font-size: 1.5em !important; } |
| .poregcn-header::after { display: none; } |
| .scenario-grid { grid-template-columns: 1fr; } |
| } |
| """ |
|
|
|
|
| |
| |
| |
|
|
| def make_header_html() -> str: |
| """Title bar with subtitle and metric badges.""" |
| return """ |
| <div class="poregcn-header"> |
| <h1>Pore<span>GCN</span>   MOF Property Predictor</h1> |
| <p class="subtitle"> |
| Heterogeneous graph neural network with Voronoi pore nodes for predicting |
| geometric, gas adsorption, and thermal properties of metal-organic frameworks. |
| Per-atom and per-pore XAI attributions with iRASPA CIF export. |
| </p> |
| <div class="header-badges"> |
| <span class="hbadge hbadge-accent">CoRE MOF • 7 properties</span> |
| <span class="hbadge hbadge-accent">hMOF Gas • 20 properties</span> |
| <span class="hbadge">Voronoi pore nodes</span> |
| <span class="hbadge">Ensemble XAI</span> |
| <span class="hbadge">Scenario A/B/C/D trustworthiness</span> |
| <span class="hbadge">iRASPA attribution export</span> |
| </div> |
| </div> |
| """ |
|
|
|
|
| def make_welcome_html() -> str: |
| """Placeholder shown before any prediction runs.""" |
| return """ |
| <div class="welcome-state"> |
| <p class="welcome-title">Ready for analysis</p> |
| <p>Upload a MOF CIF file on the left, select the ensemble, and click <b>Run Prediction</b>.</p> |
| <p style="margin-top: 10px; font-size: 0.85em;"> |
| Or load one of the example structures below the upload field. |
| </p> |
| </div> |
| """ |
|
|
|
|
| def _scenario_pill(letter: str, label: str) -> str: |
| """Inline scenario pill HTML.""" |
| return ( |
| f'<span class="scenario-pill scenario-{letter}" ' |
| f'aria-label="Scenario {letter}: {label}">' |
| f'{letter} — {label}</span>' |
| ) |
|
|
|
|
| def _cv_class(cv: float) -> str: |
| if cv < 0.05: |
| return "cv-low" |
| if cv < 0.15: |
| return "cv-mid" |
| return "cv-high" |
|
|
|
|
| def make_predictions_table_html( |
| predictions: Dict, |
| scenarios: Dict, |
| prop_names: List[str], |
| ) -> str: |
| """ |
| Sortable HTML table of predictions. |
| predictions: {prop: {mean, std, cv}} |
| scenarios: {prop: (letter, label)} |
| prop_names: ordered list to control row order |
| """ |
| if not predictions: |
| return make_welcome_html() |
|
|
| rows = "" |
| best_prop, best_cv = None, 1e9 |
| for prop in prop_names: |
| if prop not in predictions: |
| continue |
| info = predictions[prop] |
| mean_val = info.get("mean", float("nan")) |
| std_val = info.get("std", float("nan")) |
| cv_val = info.get("cv", float("nan")) |
| letter, label = scenarios.get(prop, ("D", "Unreliable")) |
|
|
| |
| if letter == "A" and cv_val < best_cv: |
| best_cv = cv_val |
| best_prop = prop |
|
|
| |
| meta = PROPERTY_META.get(prop, {}) |
| display_name = meta.get("label", prop) |
| unit_str = meta.get("unit", "") |
| desc_str = meta.get("description", "") |
|
|
| val_str = f"{mean_val:.4g}" if not (isinstance(mean_val, float) and np.isnan(mean_val)) else "—" |
| std_str = f"±{std_val:.3g}" if not (isinstance(std_val, float) and np.isnan(std_val)) else "" |
| if unit_str: |
| val_str += f" <span style='font-size:0.82em;color:#94a3b8;'>{unit_str}</span>" |
|
|
| cv_pct = f"{cv_val:.1%}" if not (isinstance(cv_val, float) and np.isnan(cv_val)) else "—" |
| cv_cls = _cv_class(cv_val) if not (isinstance(cv_val, float) and np.isnan(cv_val)) else "" |
|
|
| pill = _scenario_pill(letter, label) |
|
|
| rows += f""" |
| <tr> |
| <td class="prop-name"> |
| {display_name} |
| {'<span class="prop-desc">' + desc_str + '</span>' if desc_str else ''} |
| </td> |
| <td class="pred-val"> |
| {val_str} |
| <span class="pred-std">{std_str}</span> |
| </td> |
| <td class="cv-val {cv_cls}">{cv_pct}</td> |
| <td class="scenario-cell">{pill}</td> |
| </tr> |
| """ |
|
|
| |
| if best_prop: |
| meta = PROPERTY_META.get(best_prop, {}) |
| best_label = meta.get("label", best_prop) |
| best_mean = predictions[best_prop].get("mean", float("nan")) |
| best_std = predictions[best_prop].get("std", float("nan")) |
| summary_html = ( |
| f'<div class="summary-bar">' |
| f'Most trustworthy: <strong>{best_label}</strong> = ' |
| f'<strong>{best_mean:.4g} ± {best_std:.3g}</strong> ' |
| f'[Scenario A, CV = {best_cv:.1%}]' |
| f'</div>' |
| ) |
| else: |
| |
| summary_html = ( |
| '<div class="summary-bar" style="border-color:#f59e0b;">' |
| 'No Scenario A predictions. All results carry elevated uncertainty — ' |
| 'physical validation is recommended.' |
| '</div>' |
| ) |
|
|
| return f""" |
| <table class="pred-table" role="table" aria-label="Prediction results"> |
| <thead> |
| <tr> |
| <th scope="col">Property</th> |
| <th scope="col" style="text-align:right;">Prediction (mean ± std)</th> |
| <th scope="col" style="text-align:center;">CV</th> |
| <th scope="col" style="text-align:center;">Scenario</th> |
| </tr> |
| </thead> |
| <tbody>{rows}</tbody> |
| </table> |
| {summary_html} |
| """ |
|
|
|
|
| def make_viewer_info_html(prop_name: str, has_pores: bool = True) -> str: |
| """Small info panel above the 3D plot.""" |
| meta = PROPERTY_META.get(prop_name, {}) |
| label = meta.get("label", prop_name) |
| pore_note = ( |
| "Pores: translucent yellow void mesh. Cavity attribution: small " |
| "orange (positive) or blue (negative) bead at each cavity centre, " |
| "labelled with its signed contribution (size scales with magnitude). " |
| if has_pores else |
| "Voronoi unavailable — atom contributions only. " |
| ) |
| return ( |
| f'<div class="viewer-info-panel">' |
| f'Atoms colored by attribution to <span class="highlight">{label}</span>. ' |
| f'{pore_note}' |
| f'Blue = negative contribution • Red = positive contribution • ' |
| f'White = near-zero' |
| f'</div>' |
| ) |
|
|
|
|
| def make_top_atoms_html(per_atom, structure_symbols: List[str], n: int = 6) -> str: |
| """Top-N atoms by absolute attribution magnitude.""" |
| if per_atom is None or len(per_atom) == 0 or not structure_symbols: |
| return "" |
|
|
| |
| try: |
| indexed = [(i, float(a)) for i, a in enumerate(per_atom)] |
| indexed.sort(key=lambda x: abs(x[1]), reverse=True) |
| top = indexed[:n] |
| except Exception: |
| return "" |
|
|
| parts = [] |
| for idx, val in top: |
| sym = structure_symbols[idx] if idx < len(structure_symbols) else "?" |
| sign = "+" if val >= 0 else "" |
| cls = "atom-pos" if val >= 0 else "atom-neg" |
| parts.append(f'<span class="{cls}">{sym}[{idx}] {sign}{val:.3f}</span>') |
|
|
| return ( |
| f'<div class="top-atoms-bar">' |
| f'<span class="ta-label">Top contributing atoms (by magnitude)</span>' |
| + "  |  ".join(parts) |
| + f'</div>' |
| ) |
|
|
|
|
| def make_scenario_legend_html() -> str: |
| """4-cell colored grid explaining A/B/C/D framework.""" |
| cv_pct = f"{CV_THRESHOLD:.0%}" |
| agr_pct = f"{AGREEMENT_THRESHOLD:.0%}" |
| return f""" |
| <div class="scenario-grid" role="list" aria-label="Scenario definitions"> |
| <div class="scenario-cell sc-A" role="listitem"> |
| <div class="sc-letter">A</div> |
| <div class="sc-title">Trustworthy</div> |
| <p class="sc-desc"> |
| Ensemble agrees and XAI attributions align with the physical |
| direction of the property. |
| </p> |
| <div class="sc-criteria"> |
| CV < {cv_pct} • agreement ≥ {agr_pct} |
| </div> |
| </div> |
| <div class="scenario-cell sc-B" role="listitem"> |
| <div class="sc-letter">B</div> |
| <div class="sc-title">Overconfident</div> |
| <p class="sc-desc"> |
| Ensemble agrees on the value, but attributions do not align |
| with physical expectation. Interpret with caution. |
| </p> |
| <div class="sc-criteria"> |
| CV < {cv_pct} • agreement < {agr_pct} |
| </div> |
| </div> |
| <div class="scenario-cell sc-C" role="listitem"> |
| <div class="sc-letter">C</div> |
| <div class="sc-title">Underconfident</div> |
| <p class="sc-desc"> |
| XAI attributions are consistent but ensemble variance is high. |
| The model is uncertain despite coherent explanations. |
| </p> |
| <div class="sc-criteria"> |
| CV ≥ {cv_pct} • agreement ≥ {agr_pct} |
| </div> |
| </div> |
| <div class="scenario-cell sc-D" role="listitem"> |
| <div class="sc-letter">D</div> |
| <div class="sc-title">Unreliable</div> |
| <p class="sc-desc"> |
| High ensemble variance and incoherent attributions. Physical |
| validation required before acting on this prediction. |
| </p> |
| <div class="sc-criteria"> |
| CV ≥ {cv_pct} • agreement < {agr_pct} |
| </div> |
| </div> |
| </div> |
| """ |
|
|
|
|
| def make_substructure_breakdown_html(breakdown: Dict, prop_name: str = "") -> str: |
| """Three horizontal bars: metal % | linker % | pore %.""" |
| if not breakdown: |
| return "" |
|
|
| meta = PROPERTY_META.get(prop_name, {}) |
| label = meta.get("label", prop_name) if prop_name else "selected property" |
|
|
| metal_frac = breakdown.get("metal_frac", 0.0) |
| linker_frac = breakdown.get("linker_frac", 0.0) |
| pore_frac = breakdown.get("pore_frac", 0.0) |
|
|
| def _bar(frac: float, css_class: str, name: str) -> str: |
| pct = max(0, min(100, int(frac * 100))) |
| return f""" |
| <div class="breakdown-row"> |
| <div class="breakdown-label"> |
| <span class="breakdown-name">{name}</span> |
| <span class="breakdown-pct">{pct}%</span> |
| </div> |
| <div class="breakdown-track" role="progressbar" |
| aria-valuenow="{pct}" aria-valuemin="0" aria-valuemax="100" |
| aria-label="{name} attribution {pct}%"> |
| <div class="breakdown-fill {css_class}" |
| style="width: {pct}%;"></div> |
| </div> |
| </div> |
| """ |
|
|
| title = f"Attribution breakdown — {label}" if label else "Attribution breakdown" |
|
|
| return f""" |
| <div class="breakdown-section"> |
| <div class="breakdown-title">{title}</div> |
| {_bar(metal_frac, "fill-metal", "Metal nodes")} |
| {_bar(linker_frac, "fill-linker", "Linker / organic nodes")} |
| {_bar(pore_frac, "fill-pore", "Pore nodes (Voronoi)")} |
| </div> |
| """ |
|
|
|
|
| def make_iraspa_instructions_html() -> str: |
| """Step-by-step iRASPA workflow for the Download tab.""" |
| return """ |
| <div class="iraspa-instructions"> |
| <p> |
| The CIF file below encodes per-atom XAI attributions in the |
| <code>_atom_site_B_iso_or_equiv</code> column (B-factor field). |
| Values are scaled from 1 (most negative attribution) through 50 (neutral) |
| to 99 (most positive attribution). |
| </p> |
| <span class="step-label">Step 1 — Open in iRASPA</span> |
| Drag the downloaded CIF into iRASPA, or use |
| <code>File → Open…</code>. |
| |
| <span class="step-label">Step 2 — Color by attribution</span> |
| In the right-hand panel: <code>Appearance → Atoms → Color by → |
| Temperature Factor</code>. Select a blue-white-red colormap for clearest contrast. |
| |
| <span class="step-label">Step 3 — Other viewers</span> |
| The same B-factor column is read by Mercury, OVITO, ChimeraX, VESTA, and any |
| CIF viewer that supports B-factor coloring. In VESTA, use |
| <code>Edit → Color Settings → Isosurface / B-factors</code>. |
| |
| <span class="step-label">Interpretation</span> |
| Blue atoms drive the property toward lower values; red atoms drive it higher. |
| White atoms contribute near-zero. For properties like void fraction and surface |
| area, atoms with high positive attribution are adjacent to the pore interior |
| and are strong candidates for chemical modification. |
| </div> |
| """ |
|
|
|
|
| def make_ensembles_explainer_html() -> str: |
| """About-Ensembles tab content. Explains the three trained ensembles, |
| which kind of MOF each is best for, and why hMOF Gas is the default |
| recommendation. Reuses the iraspa-instructions/step-label CSS so the |
| visual style matches the Download tab.""" |
| return """ |
| <div class="iraspa-instructions"> |
| <p> |
| PoreGCN ships three trained ensembles. Each is a 5-fold cross-validation |
| top-k selection trained with multi-task learning and inverse-std loss |
| weighting. Pick the ensemble whose training distribution most closely |
| matches your CIF. |
| </p> |
| |
| <span class="step-label">hMOF Gas — default, broadest target list</span> |
| Trained on 51,163 hypothetical MOFs from the Wilmer hMOF database. |
| Predicts five geometric properties plus 14 gas adsorption capacities |
| (CO<sub>2</sub>, N<sub>2</sub>, CH<sub>4</sub>, H<sub>2</sub> across multiple |
| pressures and temperatures) plus log<sub>10</sub>(CO<sub>2</sub>/N<sub>2</sub>) |
| selectivity. Best for new MOFs whose target use case is gas separation, |
| carbon capture, or hydrogen storage. Example MOF in this app: |
| <b>HKUST-1</b> (Cu paddlewheel, supports the CO<sub>2</sub>/N<sub>2</sub> |
| selectivity narrative). |
| |
| <span class="step-label">hMOF Geometric — lighter, geometry only</span> |
| Same training set as hMOF Gas (51,163 hypothetical MOFs) but predicts only |
| the five geometric properties: void fraction (VF), gravimetric surface area |
| (GSA), accessible surface area (ASA), largest cavity diameter (LCD), and |
| pore-limiting diameter (PLD). Use this when you only need pore geometry |
| for a screening campaign and want the smaller, faster model. Example MOF |
| in this app: <b>MOF-5</b> (canonical high-VF Zn IRMOF, the geometric |
| benchmark in most MOF reviews). |
| |
| <span class="step-label">CoRE MOF — experimental structures, stability</span> |
| Trained on 2,737 EXPERIMENTAL MOFs from the CoRE MOF 2019 database. Predicts |
| ASA, GSA, VF, LCD, PLD, thermal stability, and density. Use this when you |
| have a synthesized MOF and care about thermodynamic descriptors that the |
| hypothetical-MOF ensembles do not predict. The smaller training set means |
| predictions are grounded in real experimental structures, but the chemical |
| space coverage is narrower than the hMOF ensembles. Two example MOFs in |
| this app are paired with this ensemble. <b>UiO-66</b> is the canonical |
| experimental benchmark for stability studies; it lands Scenario A on |
| most CoRE MOF properties. <b>Tb-MOF-CrystEngComm2023</b> is a CoRE MOF |
| validation example selected from a dual-XAI-method search across all |
| 2,737 training entries: it lands Scenario A on 5 of 7 properties under |
| BOTH attribution methods (signed occlusion and gradient x input), with |
| sub-5% relative error on each, while the remaining 2 properties (GSA, |
| ASA) are correctly flagged Scenario C and the predictions are 20-41% |
| off the ground-truth values. The example concretely demonstrates the |
| trustworthiness framework as a working filter, regardless of which |
| XAI method the live tool runs. |
| |
| <span class="step-label">Why hMOF Gas is the default</span> |
| It has the broadest target list and is trained on the largest dataset. |
| For a new user uploading a CIF without a specific property in mind, the |
| gas-adsorption ensemble returns the most useful per-prediction information. |
| Switch to hMOF Geometric if your question is purely geometric and you want |
| faster inference; switch to CoRE MOF if your structure is experimental and |
| you need stability or density. |
| |
| <span class="step-label">A caution on the trustworthiness scenario</span> |
| The Trustworthiness tab classifies each prediction A/B/C/D based on |
| ensemble agreement and XAI directional consistency. Only Scenario A |
| predictions are recommended for downstream screening without further |
| validation. The choice of ensemble does not change this classification, |
| but a CIF that sits well outside an ensemble's training distribution |
| is more likely to fall into Scenario C or D. |
| |
| <span class="step-label">XAI method — Fast vs Slow attribution</span> |
| The "XAI method" radio in the input panel selects how per-atom and |
| per-pore attributions are computed. |
| <br><br> |
| <b>The predicted values are the same regardless of method.</b> The |
| five-model ensemble produces the same forward pass either way, so |
| the property numbers in the table do not change when you flip the |
| radio. What changes is how each atom's contribution to the prediction |
| is estimated, and that contribution then feeds the Scenario A/B/C/D |
| classification. Two methods can therefore agree on the predicted |
| value while disagreeing on whether to flag the prediction as |
| trustworthy. |
| <br><br> |
| <b>Fast (default, ~35 sec) — gradient x input, one pass per property.</b> |
| For each property, the model is run forward and backward once. |
| Each atom's contribution is read off the gradient of the prediction |
| with respect to that atom's input features. This is the model's |
| local sensitivity to each atom. Cheap, snappy, and good enough for |
| most use cases. Each row in the table gets its own classification. |
| <br><br> |
| <b>Slow (~3 min on the target property) — signed occlusion.</b> |
| Each atom is removed (its features zeroed) one at a time, the |
| model is re-run, and the recorded change in prediction is that |
| atom's contribution. Same procedure for each Voronoi pore vertex. |
| Slower because the model is re-evaluated once per atom, but the |
| contribution is measured directly from how the prediction actually |
| changes rather than estimated from a gradient. The target property |
| selected in the XAI target dropdown is computed this way; the |
| other rows in the table fall back to the Fast surrogate so the |
| table stays complete (running Slow across all properties would |
| take twenty-plus minutes per click). |
| <br><br> |
| <b>Why Fast and Slow can disagree on the Scenario column.</b> |
| Fast asks "how sensitive is the prediction to each atom's input |
| features?". Slow asks "how does the prediction change if this atom |
| is not there?". The two questions are related but not identical, |
| and they disagree most on properties whose value depends on a |
| balance of contributions (e.g. pore-geometry properties where many |
| atoms collectively define the cavity shape rather than one atom |
| type dominating). When they disagree, Slow is the more conservative |
| measurement because it interrogates the prediction directly rather |
| than through a gradient approximation. |
| <br><br> |
| <b>How best to use the tool, in practice.</b> |
| <ul style="margin:6px 0 0 18px;padding:0;"> |
| <li><b>Everyday exploration and presentations:</b> Fast mode. |
| All scenarios independently classified in under a minute.</li> |
| <li><b>Rigorous classification on a specific property:</b> |
| Slow mode, with that property selected in the XAI target |
| dropdown. Other rows in the table use Fast to keep the |
| table complete.</li> |
| </ul> |
| </div> |
| """ |
|
|
|
|
| def make_error_html(title: str, detail: str, suggestion: str = "") -> str: |
| """Red-bordered error card — no Python traceback exposed.""" |
| sug_html = ( |
| f'<p style="margin-top:8px; font-size:0.9em;"><b>Suggestion:</b> {suggestion}</p>' |
| if suggestion else "" |
| ) |
| return ( |
| f'<div class="error-card" role="alert">' |
| f'<div class="err-title">{title}</div>' |
| f'<p>{detail}</p>{sug_html}' |
| f'</div>' |
| ) |
|
|
|
|
| def make_warning_html(message: str) -> str: |
| """Amber warning card.""" |
| return ( |
| f'<div class="warning-card" role="alert">' |
| f'<b>Note:</b> {message}' |
| f'</div>' |
| ) |
|
|
|
|
| def make_footer_html() -> str: |
| """Author, affiliation, contact, citation, GitHub, license, plus an |
| optional Goatcounter visitor-tracking pixel. |
| |
| To activate visitor analytics: |
| 1. Sign up at https://www.goatcounter.com (free, no cookies, GDPR-friendly). |
| 2. Note the code at the start of your dashboard URL (e.g. for |
| https://mujeebonawole.goatcounter.com the code is "mujeebonawole"). |
| 3. On the HF Space, go to Settings -> Variables and Secrets, add a |
| **Variable** (not secret) named GOATCOUNTER_CODE with that value. |
| 4. The pixel below activates on the next page load. Goatcounter dashboard |
| shows daily/weekly/monthly visit counts and country breakdown. |
| """ |
| gc_code = os.environ.get('GOATCOUNTER_CODE', '').strip() |
| if gc_code: |
| |
| gc_pixel = ( |
| f'<img src="https://{gc_code}.goatcounter.com/count' |
| f'?p=/poregcn-hf" alt="" referrerpolicy="no-referrer-when-downgrade" ' |
| f'style="position:absolute;width:1px;height:1px;opacity:0;">' |
| ) |
| else: |
| gc_pixel = '' |
| return f""" |
| <div class="poregcn-footer"> |
| PoreGCN v1.0 • |
| Abdulmujeeb T. Onawole • |
| The University of Queensland • |
| <a href="mailto:atonawole@gmail.com">atonawole@gmail.com</a> / |
| <a href="mailto:a.onawole@uq.edu.qa">a.onawole@uq.edu.qa</a> • |
| Trained on CoRE MOF, hMOF Geometric, hMOF Gas • |
| <a href="https://github.com/MujeebOnawole/PoreGCN" target="_blank" |
| rel="noopener noreferrer">GitHub</a> • |
| Onawole, A. T. (2026). <em>PoreGCN: Pore-Aware Graph Neural Network |
| for MOF Property Prediction with Explainability.</em> • |
| <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" |
| rel="noopener noreferrer">MIT License</a> |
| {gc_pixel} |
| </div> |
| """ |
|
|
|
|
| |
| |
| |
|
|
| def get_examples() -> List[List]: |
| """ |
| Build gr.Examples input list from example_cifs/ at runtime. |
| Each entry: [cif_path, dataset_key] |
| |
| Each example is paired with the ensemble it is best suited to, so a new |
| user sees one canonical MOF per ensemble before they have to choose: |
| - HKUST-1 with hmof_gas: Cu paddlewheel + CO2/N2 selectivity narrative |
| - MOF-5 with hmof_geometric: canonical high-VF Zn IRMOF, geometric benchmark |
| - UiO-66 with core_mof: experimental, stable, dense Zr MOF |
| - Tb-MOF-CrystEngComm2023 with core_mof: CoRE MOF validation example |
| selected from a dual-XAI-method search (signed occlusion + gradient |
| x input). 5 of 7 properties land Scenario A under both XAI methods |
| with sub-5% error on each; the remaining 2 (GSA, ASA) are correctly |
| flagged Scenario C and the predictions are 20-41% off the |
| ground-truth values. Demonstrates the trustworthiness framework |
| as a working filter, regardless of whether the live tool runs the |
| Fast (gradient x input) or Full (signed occlusion) attribution. |
| Unknown CIFs fall through to alphabetical cycling across the three ensembles. |
| """ |
| if not os.path.isdir(EXAMPLES_DIR): |
| return [] |
|
|
| cifs = sorted(f for f in os.listdir(EXAMPLES_DIR) if f.lower().endswith(".cif")) |
| if not cifs: |
| return [] |
|
|
| KNOWN_MOFS = { |
| "HKUST-1.cif": "hmof_gas", |
| "MOF-5.cif": "hmof_geometric", |
| "UiO-66.cif": "core_mof", |
| "Tb-MOF-CrystEngComm2023.cif": "core_mof", |
| "ZIF-8.cif": "hmof_gas", |
| "Mg-MOF-74.cif": "hmof_gas", |
| } |
| ds_cycle = ["hmof_gas", "core_mof", "hmof_geometric"] |
| examples = [] |
| fallback_idx = 0 |
| for cif_name in cifs: |
| if cif_name in KNOWN_MOFS: |
| ds = KNOWN_MOFS[cif_name] |
| else: |
| ds = ds_cycle[fallback_idx % len(ds_cycle)] |
| fallback_idx += 1 |
| examples.append([os.path.join(EXAMPLES_DIR, cif_name), ds]) |
| return examples |
|
|
|
|
| |
| |
| |
|
|
| def properties_for_dataset(dataset: str) -> List[str]: |
| """Return ordered property list for a dataset key.""" |
| if not BACKEND_AVAILABLE or dataset not in _ENSEMBLES or _ENSEMBLES[dataset] is None: |
| |
| from config import DATASET_PRESETS |
| return list(DATASET_PRESETS.get(dataset, {}).get("property_names", [])) |
| return list(_ENSEMBLES[dataset]["prop_names"]) |
|
|
|
|
| |
| |
| |
|
|
| def run_prediction(cif_file, dataset: str, target_prop: str, xai_method: str = "Fast (gradient x input, ~5 sec)", progress=gr.Progress()): |
| """ |
| Main handler: CIF -> graph -> ensemble predict -> XAI -> HTML outputs. |
| |
| Returns tuple matching the gr.Blocks outputs list: |
| (predictions_html, viewer_info_html, top_atoms_html, |
| viewer_html_3d, trust_legend_html, breakdown_html, |
| iraspa_path, status_text, warning_html) |
| """ |
| empty = ( |
| make_welcome_html(), "", "", |
| "", |
| make_scenario_legend_html(), "", |
| None, "Upload a CIF to begin.", "" |
| ) |
|
|
| if cif_file is None: |
| return empty |
|
|
| if not BACKEND_AVAILABLE: |
| err = make_error_html( |
| "Backend unavailable", |
| f"The inference modules could not be imported: {_backend_err_msg}", |
| "Check that config.py, xai_engine.py, build_graph.py, and visualize.py " |
| "are present in the Space root." |
| ) |
| return (err, "", "", "", make_scenario_legend_html(), "", None, None, |
| "Error: backend unavailable.", "") |
|
|
| ens_info = _ENSEMBLES.get(dataset) |
| if ens_info is None: |
| err = make_error_html( |
| "Ensemble not loaded", |
| f"The '{dataset}' ensemble failed to load at startup.", |
| "Check that model checkpoints are present in models/ " |
| "and that the checkpoint manifest is valid." |
| ) |
| return (err, "", "", "", make_scenario_legend_html(), "", None, None, |
| "Error: ensemble not loaded.", "") |
|
|
| warning_parts = [] |
|
|
| try: |
| cif_path = cif_file if isinstance(cif_file, str) else str(cif_file) |
| progress(0.05, desc="Parsing CIF...") |
|
|
| |
| try: |
| graph = cif_to_graph(cif_path, dataset) |
| except Exception as e: |
| err_msg = str(e) |
| if "element" in err_msg.lower() or "vocab" in err_msg.lower(): |
| return ( |
| make_error_html( |
| "Unknown element", |
| f"The CIF contains an element not in the vocabulary: {err_msg}", |
| "Try a MOF with common elements (Zr, Cu, Zn, Fe, Co, Al, C, N, O, H)." |
| ), |
| "", "", "", make_scenario_legend_html(), "", None, None, |
| f"Error: {err_msg}", "" |
| ) |
| if "voronoi" in err_msg.lower() or "zeo" in err_msg.lower(): |
| warning_parts.append( |
| "Voronoi unavailable — running in atom-only mode. " |
| "Pore-dependent properties may be less accurate." |
| ) |
| graph = cif_to_graph(cif_path, dataset) |
| else: |
| return ( |
| make_error_html( |
| "CIF parse error", |
| f"Could not parse the CIF file: {err_msg}", |
| "Verify the file is a valid CIF (try opening in Mercury or VESTA). " |
| "Consider one of the provided example structures." |
| ), |
| "", "", "", make_scenario_legend_html(), "", None, None, |
| f"Error: {err_msg}", "" |
| ) |
|
|
| progress(0.20, desc="Running ensemble...") |
|
|
| models = ens_info["models"] |
| best_model = ens_info["best_model"] |
| normalizer = ens_info["normalizer"] |
| prop_names = ens_info["prop_names"] |
|
|
| |
| predictions = ensemble_predict(graph, models, normalizer, DEVICE) |
|
|
| progress(0.45, desc="Computing XAI attributions...") |
|
|
| |
| if target_prop not in prop_names: |
| target_prop = prop_names[0] |
|
|
| |
| |
| |
| |
| |
| |
| prop_means: Dict[str, float] = {} |
| if BACKEND_AVAILABLE and dataset in _ENSEMBLES and _ENSEMBLES[dataset]: |
| prop_means = _ENSEMBLES[dataset].get("property_means", {}) or {} |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _xm_lc = (xai_method or "").lower().strip() |
| method_key = "full" if _xm_lc.startswith(("slow", "full")) else "fast" |
|
|
| n_props = len(prop_names) |
|
|
| |
| per_prop_xai: Dict[str, Dict] = {} |
|
|
| if method_key == "full": |
| |
| def _xai_progress(step, total, msg): |
| frac = 0.30 + 0.40 * (step / max(total, 1)) |
| progress(min(frac, 0.70), desc=msg) |
| progress(0.30, desc="Full XAI on target (signed occlusion, ~3 minutes)...") |
| target_res = compute_attributions( |
| graph, best_model, normalizer, prop_means, target_prop, DEVICE, |
| xai_method='full', progress_callback=_xai_progress, |
| ) |
| per_prop_xai[target_prop] = target_res |
| |
| other_props = [p for p in prop_names if p != target_prop] |
| for i, prop in enumerate(other_props, 1): |
| progress(0.70 + 0.10 * i / max(len(other_props), 1), |
| desc=f"Fast XAI for {prop} ({i}/{len(other_props)})...") |
| per_prop_xai[prop] = compute_attributions( |
| graph, best_model, normalizer, prop_means, prop, DEVICE, |
| xai_method='fast', |
| ) |
| else: |
| |
| for i, prop in enumerate(prop_names, 1): |
| progress(0.30 + 0.40 * i / max(n_props, 1), |
| desc=f"Fast XAI for {prop} ({i}/{n_props})...") |
| per_prop_xai[prop] = compute_attributions( |
| graph, best_model, normalizer, prop_means, prop, DEVICE, |
| xai_method='fast', |
| ) |
|
|
| |
| target_xai = per_prop_xai.get(target_prop, {}) |
| per_atom = target_xai.get("per_atom", []) |
| per_pore = target_xai.get("per_pore", []) |
| exp_dir = target_xai.get("expected_direction", "+") |
|
|
| progress(0.80, desc="Classifying scenarios...") |
|
|
| |
| |
| |
| |
| |
| scenarios: Dict[str, Tuple[str, str]] = {} |
| for prop in prop_names: |
| pinfo = predictions.get(prop, {}) |
| prop_mean = prop_means.get(prop, float("nan")) |
| p_xai = per_prop_xai.get(prop, {}) |
| letter, s_label = classify_scenario( |
| pinfo.get("mean", float("nan")), |
| pinfo.get("std", float("nan")), |
| float(p_xai.get("agreement_frac", 0.0)), |
| prop_mean, |
| mean_signed=float(p_xai.get("mean_signed", 0.0)), |
| expected_positive=bool(p_xai.get("expected_positive", True)), |
| ) |
| scenarios[prop] = (letter, s_label) |
| agr_frac = float(target_xai.get("agreement_frac", 0.0)) |
|
|
| progress(0.70, desc="Building predictions table...") |
|
|
| predictions_html = make_predictions_table_html(predictions, scenarios, prop_names) |
| warning_html = "".join(make_warning_html(w) for w in warning_parts) |
|
|
| progress(0.78, desc="Computing substructure breakdown...") |
|
|
| breakdown = substructure_breakdown(graph, per_atom, per_pore) |
| breakdown_html = make_substructure_breakdown_html(breakdown, target_prop) |
|
|
| progress(0.84, desc="Rendering 3D visualization...") |
|
|
| |
| structure = graph.get("structure") |
| pore_pos = graph.get("pore_positions", []) |
| pore_radii = graph.get("pore_radii", []) |
| has_pores = len(pore_pos) > 0 |
|
|
| try: |
| viewer_html_3d = create_3d_visualization( |
| structure, per_atom, per_pore, pore_pos, pore_radii, target_prop |
| ) |
| except Exception: |
| viewer_html_3d = "" |
|
|
| viewer_info_html = make_viewer_info_html(target_prop, has_pores) |
|
|
| |
| symbols = [] |
| try: |
| if hasattr(structure, "get_chemical_symbols"): |
| symbols = structure.get_chemical_symbols() |
| elif hasattr(structure, "species"): |
| symbols = [str(s) for s in structure.species] |
| except Exception: |
| pass |
| top_atoms_html = make_top_atoms_html(per_atom, symbols) |
|
|
| progress(0.92, desc="Exporting iRASPA CIF...") |
|
|
| iraspa_path = None |
| try: |
| tmp_dir = tempfile.mkdtemp() |
| formula = "mof" |
| if structure is not None: |
| try: |
| if hasattr(structure, "get_chemical_formula"): |
| formula = structure.get_chemical_formula() |
| elif hasattr(structure, "composition"): |
| formula = structure.composition.reduced_formula |
| except Exception: |
| pass |
| out_path = os.path.join(tmp_dir, f"{formula}_attribution.cif") |
| iraspa_path = export_iraspa_cif(structure, per_atom, out_path) |
| except Exception: |
| iraspa_path = None |
|
|
| |
| try: |
| csv_path = os.path.join(tmp_dir, f"{formula}_{target_prop}_attribution.csv") |
| csv_out_path = export_attribution_csv( |
| structure=structure, |
| per_atom_attrs=per_atom, |
| pore_positions=np.asarray(graph.get("pore_positions", np.zeros((0, 3)))), |
| pore_radii=np.asarray(graph.get("pore_radii", np.zeros(0))), |
| per_pore_attrs=per_pore if per_pore is not None else np.zeros(0), |
| property_name=target_prop, |
| output_path=csv_path, |
| ) |
| except Exception: |
| csv_out_path = None |
|
|
| progress(1.0, desc="Done.") |
|
|
| |
| n_atoms = len(per_atom) if per_atom is not None else 0 |
| n_pores = len(per_pore) if per_pore is not None else 0 |
| scenario_A_props = [p for p, (l, _) in scenarios.items() if l == "A"] |
| status = ( |
| f"{n_atoms} atoms, {n_pores} pores | " |
| f"{len(prop_names)} properties | " |
| f"Scenario A: {len(scenario_A_props)}/{len(prop_names)} | " |
| f"XAI target: {target_prop}" |
| ) |
|
|
| |
| trust_legend_html = make_scenario_legend_html() |
|
|
| return ( |
| predictions_html, |
| viewer_info_html, |
| top_atoms_html, |
| viewer_html_3d, |
| trust_legend_html, |
| breakdown_html, |
| iraspa_path, |
| csv_out_path, |
| status, |
| warning_html, |
| ) |
|
|
| except Exception as e: |
| traceback.print_exc() |
| return ( |
| make_error_html( |
| "Prediction failed", |
| f"An unexpected error occurred during prediction.", |
| "Check the structure is a valid MOF CIF. " |
| "Try one of the provided example structures." |
| ), |
| "", "", "", |
| make_scenario_legend_html(), "", |
| None, None, |
| f"Error: {type(e).__name__}", |
| "" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def update_property_dropdown(dataset: str) -> gr.Dropdown: |
| """Refresh the property dropdown when the dataset changes.""" |
| props = properties_for_dataset(dataset) |
| first = props[0] if props else None |
| return gr.Dropdown(choices=props, value=first) |
|
|
|
|
| |
| |
| |
|
|
| _dataset_choices = [(DATASET_LABELS.get(k, k), k) for k in DATASETS] |
| _default_dataset = "hmof_gas" |
| _default_props = properties_for_dataset(_default_dataset) |
| _default_prop = _default_props[0] if _default_props else None |
|
|
| with gr.Blocks( |
| css=CUSTOM_CSS, |
| title="PoreGCN: MOF Property Predictor", |
| theme=gr.themes.Base( |
| font=[gr.themes.GoogleFont("IBM Plex Sans"), "sans-serif"], |
| font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "monospace"], |
| ), |
| ) as demo: |
|
|
| |
| gr.HTML(make_header_html()) |
|
|
| |
| with gr.Row(equal_height=False): |
|
|
| |
| with gr.Column(scale=1, min_width=300, elem_classes=["input-panel"]): |
|
|
| gr.HTML('<span class="panel-label">Input</span>') |
|
|
| cif_input = gr.File( |
| label="MOF CIF file", |
| file_types=[".cif"], |
| type="filepath", |
| ) |
|
|
| dataset_dropdown = gr.Dropdown( |
| choices=_dataset_choices, |
| value=_default_dataset, |
| label="Ensemble", |
| info="Select the trained ensemble to use for prediction.", |
| ) |
|
|
| property_dropdown = gr.Dropdown( |
| choices=_default_props, |
| value=_default_prop, |
| label="XAI target property", |
| info="Property for which atom-level attributions are computed.", |
| ) |
|
|
| xai_method_radio = gr.Radio( |
| choices=[ |
| "Fast (gradient x input, ~35 sec)", |
| "Slow (signed occlusion, ~3 min on target property)", |
| ], |
| value="Fast (gradient x input, ~35 sec)", |
| label="XAI method", |
| info=( |
| "Predicted values are the same in both modes. " |
| "Only the per-atom attributions and resulting Scenario " |
| "classifications differ. Fast: snappy, suitable for " |
| "everyday use. Slow: more rigorous on the selected " |
| "target property." |
| ), |
| ) |
|
|
| run_btn = gr.Button( |
| "Run Prediction", |
| variant="primary", |
| size="lg", |
| elem_classes=["run-btn"], |
| ) |
|
|
| status_box = gr.Textbox( |
| label="Status", |
| value="Upload a CIF to begin.", |
| interactive=False, |
| elem_classes=["status-box"], |
| lines=2, |
| ) |
|
|
| |
| warning_out = gr.HTML(value="") |
|
|
| |
| _examples = get_examples() |
| if _examples: |
| gr.HTML(value=( |
| '<div style="font-size:0.85em; color:#475569; margin-top:8px; ' |
| 'padding:8px 12px; background:#f8fafc; border-left:3px solid #0891b2;">' |
| '<b>Tutorial:</b> click an example below to load a famous MOF, then press <b>Run Prediction</b>. ' |
| 'HKUST-1 (Cu paddlewheel) is the recommended starting point and reproduces the ' |
| 'manuscript\'s Cu-attribution finding for CO₂/N₂ selectivity.' |
| '</div>' |
| )) |
| gr.Examples( |
| examples=_examples, |
| inputs=[cif_input, dataset_dropdown], |
| label="Example MOF structures (click to load)", |
| examples_per_page=5, |
| ) |
|
|
| |
| with gr.Column(scale=3): |
|
|
| with gr.Tabs(): |
|
|
| |
| with gr.TabItem("Predictions", id="tab_predictions"): |
| predictions_out = gr.HTML(value=make_welcome_html()) |
|
|
| |
| with gr.TabItem("3D Attribution Viewer", id="tab_viewer"): |
| viewer_info_out = gr.HTML(value="") |
| viewer_plot_out = gr.HTML(value="") |
| top_atoms_out = gr.HTML(value="") |
|
|
| |
| with gr.TabItem("Trustworthiness", id="tab_trust"): |
| gr.HTML( |
| '<p style="font-family:\'IBM Plex Sans\',sans-serif;' |
| 'font-size:0.88em;color:#334155;margin:0 0 14px 0;' |
| 'line-height:1.7;">' |
| 'Each prediction is classified by two criteria: ' |
| '(1) ensemble coefficient of variation (CV) measures model agreement; ' |
| '(2) XAI directional agreement measures whether per-atom attributions ' |
| 'align with the expected physical direction of the property.' |
| '</p>' |
| ) |
| trust_legend_out = gr.HTML(value=make_scenario_legend_html()) |
| breakdown_out = gr.HTML(value="") |
|
|
| |
| with gr.TabItem("About Ensembles and XAI", id="tab_ensembles"): |
| gr.HTML(make_ensembles_explainer_html()) |
|
|
| |
| with gr.TabItem("Download", id="tab_download"): |
| gr.HTML(make_iraspa_instructions_html()) |
| iraspa_out = gr.File( |
| label="iRASPA-ready CIF (B-factor = attribution)", |
| visible=True, |
| ) |
| gr.HTML( |
| '<div style="margin-top:12px;padding:10px 14px;background:#f8fafc;' |
| 'border-left:3px solid #0891b2;font-family:\'IBM Plex Sans\',sans-serif;' |
| 'font-size:0.88em;line-height:1.6;color:#334155;">' |
| '<b>Per-atom and per-pore attribution (CSV):</b> ' |
| 'tabular export of every atom and Voronoi pore vertex with its ' |
| 'Cartesian coordinates and signed attribution to the selected property. ' |
| 'Use this for downstream analysis (ranking high-attribution motifs, ' |
| 'feeding into MD or DFT on selected atom subsets, or as input to ' |
| 'design tools that propose linker substitutions).' |
| '</div>' |
| ) |
| csv_out = gr.File( |
| label="Attribution CSV (per-atom + per-pore)", |
| visible=True, |
| ) |
|
|
| |
| gr.HTML(make_footer_html()) |
|
|
| |
| |
| |
|
|
| |
| dataset_dropdown.change( |
| fn=update_property_dropdown, |
| inputs=[dataset_dropdown], |
| outputs=[property_dropdown], |
| ) |
|
|
| |
| run_btn.click( |
| fn=run_prediction, |
| inputs=[cif_input, dataset_dropdown, property_dropdown, xai_method_radio], |
| outputs=[ |
| predictions_out, |
| viewer_info_out, |
| top_atoms_out, |
| viewer_plot_out, |
| trust_legend_out, |
| breakdown_out, |
| iraspa_out, |
| csv_out, |
| status_box, |
| warning_out, |
| ], |
| ) |
|
|
| |
| cif_input.change( |
| fn=run_prediction, |
| inputs=[cif_input, dataset_dropdown, property_dropdown], |
| outputs=[ |
| predictions_out, |
| viewer_info_out, |
| top_atoms_out, |
| viewer_plot_out, |
| trust_legend_out, |
| breakdown_out, |
| iraspa_out, |
| status_box, |
| warning_out, |
| ], |
| ) |
|
|
|
|
| |
| |
| |
|
|
| if __name__ == "__main__": |
| demo.launch( |
| share=False, |
| server_name="0.0.0.0", |
| server_port=7860, |
| show_error=True, |
| ) |
|
|