""" SPARK (Simulation-based Posterior Amortization for Reaction Kinetics) Amortized Bayesian inference for outer-sphere electron-transfer cyclic voltammetry. Gradio web interface for the v4 continuous-manifold model: from one or more cyclic voltammograms it returns a calibrated posterior over a continuous space of reaction mechanisms, the alternative mechanisms the data admit, per-parameter posteriors, and a posterior-predictive reconstruction. Mechanism identity is read post-hoc from which elementary-step gates the posterior places above kinetic silence -- there is no classifier. """ import os import sys import json from pathlib import Path import numpy as np import gradio as gr # spark_app dir first (local inference.py/plotting.py), repo root appended for `manifold` _HERE = Path(__file__).resolve().parent sys.path.insert(0, str(_HERE)) sys.path.append(str(_HERE.parent)) from inference import get_predictor, GATE_PRETTY from preprocessing import parse_cv_csv, estimate_E0 from plotting import ( plot_mechanism_posterior, plot_alternatives, plot_presence, plot_parameter_posteriors, plot_reconstruction, parameter_table, ) from manifold import reaction_network as RN DEMO_DIR = _HERE / "demo_data" # lazy predictor (loaded on first request so the Space boots fast / shows errors cleanly) _PRED = None _PRED_ERR = None def _predictor(): global _PRED, _PRED_ERR if _PRED is None and _PRED_ERR is None: try: _PRED = get_predictor() except Exception as e: # noqa: BLE001 _PRED_ERR = str(e) return _PRED def _mech_choice_label(m): return f"{m['label']} ({m['prob']*100:.0f}%)" def _error(msg): # order: mech_plot, presence_plot, summary, dropdown, rec_plot, param_plot, table, state return (None, None, f"### Error\n\n{msg}", gr.update(choices=[], value=None), None, None, "", None) # --------------------------------------------------------------------------- # Core analysis: raw scans -> fast posterior outputs + a mechanism selector. # Reconstruction + per-mechanism parameter posteriors are computed for the # selected mechanism (top by default; user can switch via the dropdown). # --------------------------------------------------------------------------- def _analyze(scans, E0_V, T_K, A_cm2, C_mM, D_cm2s, n_electrons, n_post, n_pred, n_scans): pred = _predictor() if pred is None: return _error(f"Model unavailable: {_PRED_ERR}") if not scans: return _error("No usable voltammograms were parsed.") C_molcm3 = float(C_mM) * 1e-6 if C_mM else 1e-6 n = int(n_electrons) if n_electrons else 1 T = float(T_K) if T_K else 298.15 A = float(A_cm2) if A_cm2 else 0.0707 D = float(D_cm2s) if D_cm2s else 1e-5 n_scans = int(n_scans); n_pred = int(n_pred) e0 = float(E0_V) if E0_V else float(np.median( [estimate_E0(s["E_V"], s["i_A"]) for s in scans])) try: exp = pred.build_exp(scans, E0_V=e0, T_K=T, A_cm2=A, C_A_molcm3=C_molcm3, D_A_cm2s=D, n=n) s = pred.sample_posterior(exp, n_scans=n_scans, n_post=int(n_post)) except Exception as e: # noqa: BLE001 return _error(f"Inference failed: {e}") mechs, _ = pred.mechanism_posterior(s, top_k=8) fig_mech = plot_mechanism_posterior(mechs) # fast: probabilities only fig_pres = plot_presence(pred.presence(s), GATE_PRETTY) summary = _summary_md(mechs) choices = [_mech_choice_label(m) for m in mechs] labelmap = {c: m["gates"] for c, m in zip(choices, mechs)} state = {"s": s, "exp": exp, "labelmap": labelmap, "n_scans": n_scans, "n_pred": n_pred} # reconstruct + inspect the top mechanism for the initial view top = mechs[0] info = pred.inspect_mechanism(s, top["gates"], exp, n_scans=n_scans, n_pred=n_pred) fig_rec = plot_reconstruction(info["panels"], top["label"], info["nrmse"]) fig_par = plot_parameter_posteriors(info["subset"], info["params"], RN.SLOT_IDX) table = "### Parameter posteriors (selected mechanism)\n\n" + parameter_table(info["params"]) dd = gr.update(choices=choices, value=choices[0]) return fig_mech, fig_pres, summary, dd, fig_rec, fig_par, table, state def on_mechanism_change(label, state): """Reconstruct + show parameter posteriors for the mechanism picked in the dropdown.""" if not state or not label: return None, None, "" pred = _predictor() gates = state["labelmap"].get(label, []) try: info = pred.inspect_mechanism(state["s"], gates, state["exp"], n_scans=state["n_scans"], n_pred=state["n_pred"]) except Exception as e: # noqa: BLE001 return None, None, f"Reconstruction failed: {e}" name = label.split(" (")[0] fig_rec = plot_reconstruction(info["panels"], name, info["nrmse"]) fig_par = plot_parameter_posteriors(info["subset"], info["params"], RN.SLOT_IDX) table = "### Parameter posteriors (selected mechanism)\n\n" + parameter_table(info["params"]) return fig_rec, fig_par, table def _summary_md(mechs): lines = ["### Mechanism inference"] top = mechs[0] lines.append(f"Most probable mechanism: **{top['label']}** " f"(posterior probability {top['prob']*100:.0f}%).") strong = [m for m in mechs if m["prob"] >= 0.15] if len(strong) >= 2: alt = "; ".join(f"{m['label']} ({m['prob']*100:.0f}%)" for m in strong) lines.append( f"\n**This voltammogram is consistent with more than one mechanism:** {alt}. " "Use the selector below to reconstruct each and inspect its parameter posterior. " "A single point fit, given one assumed mechanism, would report only one of these.") else: lines.append("\nUse the selector below to reconstruct a mechanism and inspect its " "parameter posterior.") return "\n".join(lines) # --------------------------------------------------------------------------- # CSV entry point # --------------------------------------------------------------------------- def analyze_cv_csv(files, scan_rates_text, E0_V, T_K, A_cm2, C_mM, D_cm2s, n_electrons, n_post, n_pred, n_scans): if not files: return _error("Please upload at least one CSV file (one per scan rate).") rates_text = (scan_rates_text or "").strip() user_rates = None if rates_text: try: user_rates = [float(s.strip()) for s in rates_text.split(",")] except ValueError: return _error("Invalid scan rates. Enter comma-separated numbers in V/s.") if len(user_rates) != len(files): return _error(f"Number of files ({len(files)}) must match number of " f"scan rates ({len(user_rates)}).") scans = [] for idx, f in enumerate(files): try: parsed = parse_cv_csv(Path(f.name).read_text()) except Exception as e: # noqa: BLE001 return _error(f"Could not parse '{Path(f.name).name}': {e}") if user_rates is not None: v = user_rates[idx] elif "scan_rate_Vs" in parsed: v = parsed["scan_rate_Vs"] else: return _error(f"Cannot determine scan rate for '{Path(f.name).name}'. " "Provide scan rates (V/s) or include a Time (s) column.") scans.append({"E_V": parsed["E_V"], "i_A": parsed["i_A"], "v_Vs": v}) return _analyze(scans, E0_V, T_K, A_cm2, C_mM, D_cm2s, n_electrons, n_post, n_pred, n_scans) # --------------------------------------------------------------------------- # Image entry point (digitize plot images) # --------------------------------------------------------------------------- def analyze_cv_image(files, scan_rates_text, E0_V, threshold, n_post, n_pred, n_scans, x_min, x_max, y_min, y_max): if not files: return _error("Please upload at least one plot image.") try: from digitizer import digitize_plot, auto_detect_axis_bounds from PIL import Image as PILImage except ImportError: return _error("Image digitization requires opencv-python-headless and Pillow.") rates_text = (scan_rates_text or "").strip() if not rates_text: return _error("Please enter scan rate(s) (V/s), comma-separated.") try: rates = [float(s.strip()) for s in rates_text.split(",")] except ValueError: return _error("Invalid scan rates.") if len(rates) == 1 and len(files) > 1: rates = rates * len(files) if len(rates) != len(files): return _error(f"Number of scan rates ({len(rates)}) must match images ({len(files)}).") has_bounds = all(v not in (None, 0) for v in [x_min, x_max, y_min, y_max]) scans = [] for idx, f in enumerate(files): fpath = f.name if hasattr(f, "name") else str(f) try: img = np.array(PILImage.open(fpath).convert("RGB")) except Exception as e: # noqa: BLE001 return _error(f"Could not read image {idx+1}: {e}") if has_bounds: b = {"x_min": float(x_min), "x_max": float(x_max), "y_min": float(y_min), "y_max": float(y_max)} else: try: b = auto_detect_axis_bounds(img) except Exception: # noqa: BLE001 (e.g. easyocr unavailable) b = None if b is None: return _error(f"Could not auto-detect axes for image {idx+1}. " "Enter E min/max and I min/max under 'Axis overrides' and retry.") try: E_V, I_raw = digitize_plot(img, b["x_min"], b["x_max"], b["y_min"], b["y_max"], threshold=int(threshold)) except Exception as e: # noqa: BLE001 return _error(f"Digitization failed for image {idx+1}: {e}") i_max = np.max(np.abs(I_raw)) i_A = I_raw * (1e-6 if i_max > 100 else 1e-3 if i_max > 0.1 else 1.0) scans.append({"E_V": E_V, "i_A": i_A, "v_Vs": rates[idx]}) return _analyze(scans, E0_V, 298.15, 0.0707, 1.0, 1e-5, 1, n_post, n_pred, n_scans) # --------------------------------------------------------------------------- # Demo examples # --------------------------------------------------------------------------- def _demo_examples(): """Group bundled demo CSVs by mechanism -> (label, [csv paths], scan_rates, E0).""" out = [] # os_* = real experimental case studies; sim_* = synthetic held-out test examples metas = sorted(DEMO_DIR.glob("os_*_metadata.json")) + sorted(DEMO_DIR.glob("sim_*_metadata.json")) for meta in metas: try: m = json.load(open(meta)) except Exception: # noqa: BLE001 continue stem = meta.name.replace("_metadata.json", "") csvs = sorted(DEMO_DIR.glob(f"{stem}_*mVs.csv")) if not csvs: continue # files are sorted by zero-padded mV (ascending); pair with ascending rates out.append({"label": m.get("system", stem), "csvs": [str(c) for c in csvs], "rates": sorted(m.get("scan_rates_Vs", [])), "E0": m.get("physical_params", {}).get("E0_V", 0.0)}) return out # --------------------------------------------------------------------------- # UI: theme, CSS, hero header, About # --------------------------------------------------------------------------- HERO_HTML = """