"""3-D molecular structure visualisation components for the Streamlit UI.""" import json import re as _re import numpy as np import pandas as pd import streamlit as st from ase.data import chemical_symbols from chemgraph.tools.ase_core import create_ase_atoms, create_xyz_string # --------------------------------------------------------------------------- # Optional stmol / py3Dmol availability # --------------------------------------------------------------------------- try: import stmol STMOL_AVAILABLE = True except ImportError: STMOL_AVAILABLE = False # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def _stable_key(prefix: str, title: str) -> str: """Return a deterministic Streamlit widget key derived from *title*. Using ``uuid4()`` caused widget keys to change on every rerun, which reset user selections (e.g. the style selectbox) and leaked stale widget state in memory. Parameters ---------- prefix : str Widget key prefix. title : str Viewer title used to derive the key suffix. Returns ------- str Stable Streamlit widget key. """ slug = _re.sub(r"[^a-z0-9]+", "_", title.lower()).strip("_") return f"{prefix}_{slug}" def warn_stmol_unavailable() -> None: """Display a one-time warning when stmol is not installed.""" if STMOL_AVAILABLE or st.session_state.get("_stmol_warning_shown"): return st.session_state["_stmol_warning_shown"] = True st.warning("**stmol** not available -- falling back to text/table view.") st.info("To enable 3D visualization, install with: `pip install stmol`") def create_ase_atoms_with_streamlit_error(atomic_numbers, positions): """Create ASE atoms and display Streamlit errors on failure. Parameters ---------- atomic_numbers : sequence Atomic numbers for each atom. positions : sequence Cartesian coordinates for each atom. Returns ------- ase.Atoms or None Constructed atoms object, or ``None`` if creation fails. """ atoms = create_ase_atoms(atomic_numbers, positions) if atoms is None: st.error("Error creating ASE Atoms object") return atoms def display_molecular_structure(atomic_numbers, positions, title="Structure") -> bool: """Render an interactive 3-D molecular viewer with info panel. Returns ``True`` on success, ``False`` on error. Parameters ---------- atomic_numbers : sequence Atomic numbers for each atom. positions : sequence Cartesian coordinates for each atom. title : str, optional Title displayed above the structure viewer. Returns ------- bool ``True`` when the structure is rendered successfully. """ try: atoms = create_ase_atoms_with_streamlit_error(atomic_numbers, positions) if atoms is None: return False xyz_string = create_xyz_string(atomic_numbers, positions) if xyz_string is None: return False st.subheader(f"\U0001f9ec {title}") col1, col2 = st.columns([2, 1]) # 3-D panel -------------------------------------------------------- with col1: if STMOL_AVAILABLE: style_options = ["ball_and_stick", "stick", "sphere", "wireframe"] selected_style = st.selectbox( "Visualization Style", style_options, key=_stable_key("style", title), ) try: import py3Dmol view = py3Dmol.view(width=500, height=400) view.addModel(xyz_string, "xyz") if selected_style == "ball_and_stick": view.setStyle({"stick": {}, "sphere": {"scale": 0.3}}) elif selected_style == "stick": view.setStyle({"stick": {}}) elif selected_style == "sphere": view.setStyle({"sphere": {}}) elif selected_style == "wireframe": view.setStyle({"line": {}}) else: view.setStyle({"stick": {}, "sphere": {"scale": 0.3}}) view.zoomTo() stmol.showmol(view, height=400, width=500) except Exception as viz_error: st.error(f"3D visualization error: {viz_error}") st.info("Falling back to table view...") _render_structure_table(atomic_numbers, positions) else: st.info("3-D viewer unavailable; showing raw XYZ and table.") with st.expander("\U0001f4c4 XYZ Format", expanded=True): st.code(xyz_string, language="text") _render_structure_table(atomic_numbers, positions) # Info panel ------------------------------------------------------- with col2: _render_structure_info(atoms, atomic_numbers, positions, xyz_string, title) return True except Exception as exc: st.error(f"Error displaying structure: {exc}") return False def visualize_trajectory(traj): """Create an animated py3Dmol view from an ASE trajectory. Parameters ---------- traj : Iterable[ase.Atoms] Trajectory frames to visualize. Returns ------- py3Dmol.view Configured animated viewer. """ import py3Dmol xyz_frames = [] for i, atoms in enumerate(traj): symbols = atoms.get_chemical_symbols() pos = atoms.get_positions() lines = [str(len(symbols)), f"Frame {i}"] lines += [ f"{s} {x:.6f} {y:.6f} {z:.6f}" for s, (x, y, z) in zip(symbols, pos) ] xyz_frames.append("\n".join(lines)) xyz_str = "\n".join(xyz_frames) view = py3Dmol.view(width=500, height=400) view.addModelsAsFrames(xyz_str, "xyz") view.setViewStyle({"style": "outline", "width": 0.05}) view.setStyle({"stick": {}, "sphere": {"scale": 0.25}}) view.zoomTo() view.animate({"loop": "Forward", "interval": 100}) return view # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _render_structure_table(atomic_numbers, positions) -> None: """Render a DataFrame table of atom positions. Parameters ---------- atomic_numbers : sequence Atomic numbers for each atom. positions : sequence Cartesian coordinates for each atom. """ data = [] for idx, (num, pos) in enumerate(zip(atomic_numbers, positions), 1): sym = chemical_symbols[num] if num < len(chemical_symbols) else f"X{num}" data.append( { "Atom": idx, "Element": sym, "X": f"{pos[0]:.4f}", "Y": f"{pos[1]:.4f}", "Z": f"{pos[2]:.4f}", } ) st.dataframe(pd.DataFrame(data), height=350, use_container_width=True) def _render_structure_info(atoms, atomic_numbers, positions, xyz_string, title) -> None: """Render the info/download panel beside the 3-D viewer. Parameters ---------- atoms : ase.Atoms Structure object. atomic_numbers : sequence Atomic numbers for each atom. positions : sequence Cartesian coordinates for each atom. xyz_string : str XYZ-format structure text. title : str Structure title used for download naming. """ st.markdown("**Structure Information**") st.write(f"- **Atoms:** {len(atoms)}") st.write(f"- **Formula:** {atoms.get_chemical_formula()}") # Composition composition: dict[str, int] = {} for atom in atoms: composition[atom.symbol] = composition.get(atom.symbol, 0) + 1 st.write("**Composition:**") for elem, count in sorted(composition.items()): st.write(f" \u2022 {elem}: {count}") # Total mass try: total_mass = atoms.get_masses().sum() st.write(f"**Total Mass:** {total_mass:.2f} amu") except Exception: st.write("**Total Mass:** Not available") # Center of mass try: com = atoms.get_center_of_mass() st.write("**Center of Mass:**") st.write(f" [{com[0]:.3f}, {com[1]:.3f}, {com[2]:.3f} ] \u00c5") except Exception: st.write("**Center of Mass:** Not available") # Additional properties with st.expander("\U0001f52c Additional Properties"): try: pos = atoms.positions com = atoms.get_center_of_mass() distances = np.linalg.norm(pos - com, axis=1) st.write(f"**Max distance from COM:** {distances.max():.3f} \u00c5") st.write(f"**Min distance from COM:** {distances.min():.3f} \u00c5") cell = atoms.get_cell() if np.any(cell.lengths()): st.write(f"**Cell lengths:** {cell.lengths()}") st.write(f"**Cell angles:** {cell.angles()}") else: st.write("**Cell:** non-periodic") except Exception as prop_error: st.write(f"Error calculating properties: {prop_error}") # Downloads st.write("**Download:**") st.download_button( "\U0001f4c4 XYZ File", xyz_string, f"{title.lower().replace(' ', '_')}.xyz", mime="chemical/x-xyz", key=_stable_key("xyz_download", title), ) structure_json = json.dumps( { "atomic_numbers": atomic_numbers, "positions": positions, "formula": atoms.get_chemical_formula(), "symbols": atoms.get_chemical_symbols(), }, indent=2, ) st.download_button( "\U0001f4cb JSON Data", structure_json, f"{title.lower().replace(' ', '_')}.json", mime="application/json", key=_stable_key("json_download", title), )