vibe-view: interactive viewer

vibe-view is a GPU-accelerated browser-based viewer for vibe-qc’s QVF (.qvf) output archives. It reads the zip + JSON manifest format produced by run_job(..., output_qvf=True) and renders every section the calculation produced: structure, electron density, molecular orbitals, band structures, spectra, geometry-optimisation trajectories, vibrational modes, NEB / IRC reaction paths, and more.

What you need on disk

Two things:

  1. A .qvf file produced by vibe-qc. Pass output_qvf=True to run_job or run_periodic_job and a {stem}.qvf lands next to {stem}.out.

  2. vibe-view installed (it is a peer subproject under vibe-view/ inside the vibe-qc checkout, with its own pyproject.toml).

Install

vibe-view is a co-located peer package, not a built-in part of vibeqc. Two install routes:

# From a vibe-qc checkout. Installs both vibe-qc and vibe-view.
pip install -e '.[viewer-gpu]'

# Or, vibe-view alone (still from the same checkout because the
# package is not yet on PyPI).
pip install -e vibe-view/

The dependency footprint is pure-pip (PyVista + Trame + VTK + Plotly + Click + Pydantic + jsonschema). No conda, no JavaScript build step, no npm. The heaviest single dep is VTK (~200 MB wheel) so the first install takes a minute or two.

Verify the install:

vibe-view --version
# vibe-view 0.1.0

Produce a .qvf file from vibe-qc

Pass output_qvf=True to either runner. The flag stacks freely with write_density, write_molden_file, and the other artefact toggles.

Molecular: H2O / PBE / 6-31G*

from vibeqc import Atom, Molecule, run_job

mol = Molecule([
    Atom(8, [0.0,  0.00,  0.00]),
    Atom(1, [0.0,  1.43, -0.98]),
    Atom(1, [0.0, -1.43, -0.98]),
])

run_job(
    mol,
    basis="6-31g*",
    method="rks",
    functional="PBE",
    optimize=True,                  # writes a geometry trajectory
    output="water",
    output_qvf=True,                # produces water.qvf
    write_density=True,             # embeds the SCF density isosurface
    write_molden_file=True,         # embeds wavefunction.gto + MOs
)

Produces water.qvf alongside the usual water.out / water.molden / water.traj / water.bibtex siblings.

Periodic: MgO rocksalt / RHF / sto-3g

import numpy as np
import vibeqc as vq

a = 4.21 / 0.529177                                    # Angstrom to bohr
mgo = vq.PeriodicSystem(
    3,
    np.eye(3) * a,
    [vq.Atom(12, [0.0, 0.0, 0.0]),
     vq.Atom(8,  [a/2, a/2, a/2])],
)
basis = vq.BasisSet(mgo.unit_cell_molecule(), "sto-3g")

kpath = vq.kpath_from_segments(
    mgo,
    segments=[
        ([0.0, 0.0, 0.0], "G", [0.5, 0.0, 0.5], "X"),
        ([0.5, 0.0, 0.5], "X", [0.5, 0.5, 0.5], "L"),
        ([0.5, 0.5, 0.5], "L", [0.0, 0.0, 0.0], "G"),
    ],
    points_per_segment=20,
)

vq.run_periodic_job(
    mgo,
    basis=basis,
    method="RHF",
    output="mgo",
    output_qvf=True,
    write_density=True,
    band_structure=vq.band_structure_hcore(mgo, basis, kpath),
)

The optional band_structure= kwarg embeds a band-structure section in the archive so vibe-view can render the interactive Plotly band plot without having to re-diagonalise.

Launch

Command line

vibe-view open water.qvf

Boots the Trame server on http://127.0.0.1:8080 and opens the default browser at that URL. Flags:

vibe-view open water.qvf --port 9876        # bind to a different port
vibe-view open water.qvf --no-browser       # skip the auto-open
vibe-view open water.qvf --host 0.0.0.0     # bind to all interfaces (remote use)

Programmatic

from vibeview import launch_qvf

launch_qvf("water.qvf")                          # path on disk
launch_qvf(open("water.qvf", "rb"))              # any seekable file-like
launch_qvf(io.BytesIO(qvf_bytes), open_browser=False)  # in-memory

launch_qvf blocks until the Trame server stops (Ctrl+C). Pass an already-constructed QVFReader instance if you want to inspect the archive before launching the UI.

The startup banner

Before the server starts, vibe-view prints a summary banner to stdout that lists every section in the archive and what it will do with each one:

╔══════════════════════════════════════════════════════════════════════════════╗
║  QVF file: water.qvf                                                         ║
║  Source:   vibe-qc 0.9.0 - water                                             ║
╠══════════════════════════════════════════════════════════════════════════════╣
║  Section ID         Kind                         Status                      ║
╠══════════════════════════════════════════════════════════════════════════════╣
║  structure          structure                    rendered                    ║
║  density            volume.density               rendered                    ║
║  homo               volume.orbital               rendered                    ║
║  lumo               volume.orbital               rendered                    ║
║  ir                 spectra.ir                   rendered                    ║
║  traj0              trajectory                   rendered                    ║
║  x_custom.notes     x_custom.notes               skipped, vendor namespace   ║
╚══════════════════════════════════════════════════════════════════════════════╝

rendered means vibe-view has a renderer for the section’s kind and will draw it. skipped, unsupported means the kind is not in the viewer’s SUPPORTED_KINDS registry. skipped, vendor namespace is the same thing but for x_<vendor>.* kinds that producers register without the viewer needing to know about them. Unknown sections never abort the open; they appear in the sidebar with the same status string.

The UI

When the browser opens you get three regions:

  • Top bar: the source banner (program, version, calculation name) and the section-status pill.

  • Sidebar (left): the section tree. Click a section to make it the active section (loads its binary data on first click). Active sections drive the main viewport.

  • Viewport (centre): 3D scene for structure / volume / vibrations / trajectory sections; interactive Plotly chart for bands / spectra; table for atom_properties and scf_history.

Structure section

Atoms are drawn as CPK spheres with element-coloured surfaces and the standard van-der-Waals radii. Bonds come from the explicit bonds section if the producer wrote one; otherwise vibe-view infers them from covalent radii. Crystals show the unit-cell wireframe.

Sidebar controls:

  • Replication (periodic only): set Nx, Ny, Nz to replicate the cell along the lattice vectors. Atoms, isosurfaces, and the cell wireframe all expand in lock-step.

  • Atom radii / colours: choose between CPK (default), van der Waals, and unit-radius schemes.

volume.density, volume.orbital, volume.spin, volume.elf, volume.difference, volume.generic

All volume kinds drive the same renderer: VTK marching cubes over the grid .dat payload, producing an isosurface at the configurable isovalue. Sidebar controls:

  • Isovalue: slider over the volume’s data range (default from viewer_defaults, falling back to a kind-specific heuristic: 0.05 e/bohr^3 for densities, 0.04 bohr^(-3/2) for MOs).

  • Colour map (signed-volume kinds): volume.orbital, volume.spin, volume.difference get a divergent map so the +/- lobes show in different colours.

  • Opacity: 0 to 1 alpha.

Volume .dat blobs are lazy-loaded: nothing is read from the zip until you activate the section. Large MO grids stay on disk until you click them.

bands

Interactive Plotly plot of every band along the k-path. The Fermi level (from the bands.fermi field) is drawn as a horizontal reference line. Hover any band to see its energy in eV at that k-point. Energies are stored in eV in QVF v1 (see QVF spatial and energy units).

spectra.ir, spectra.uvvis, spectra.raman, spectra.ecd, spectra.vcd, spectra.nmr, spectra.generic

Each spectrum renders as a Plotly stem chart of intensities vs frequency, with hover tooltips showing the per-peak metadata. The x-axis unit follows the spectrum kind (cm^-1 for IR / Raman, eV for UV-Vis, ppm for NMR).

trajectory and reaction.path

Frame-by-frame animation with a play / pause / step button strip underneath the viewport. Bonds are inferred per-frame from covalent radii so they update with the geometry. A small energy chart above the timeline plots metadata.energies vs frame index for geometry-optimisation runs.

reaction.path has the same binary layout as trajectory but additionally renders waypoint markers (reactant / TS / intermediate / product) on the timeline.

Periodic reaction paths (QVF v2). When the frames are PeriodicSystem instances (slabs, surfaces, periodic NEB trajectories), the archive ships as qvf_version: 2 and carries the per-frame lattice + dimensionality so the renderer can draw the unit cell and (eventually) wrap atoms across periodic boundaries. The schema additions are minimal: an optional lattice binary member on the reaction.path section (columns = a, b, c, in bohr, matching vibeqc.PeriodicSystem.lattice) plus a dim integer in the metadata JSON. A shared lattice across all frames stores once as shape [3, 3]; per-frame lattices store as [n_frames, 3, 3] (forward-compat with variable-cell scans). Molecular reaction paths keep emitting v1 archives — there is no migration to do. See python/vibeqc/output/formats/qvf_manifest_v2.schema.json for the canonical v2 contract; the writer detects periodic frames automatically and bumps the archive version.

Rendering (Increment B). When a v2 periodic reaction.path is activated, vibe-view draws the unit-cell wireframe in the 3D scene (the same parallelepiped style the structure renderer uses) and wraps every frame’s atom positions into the central cell along the first dim lattice vectors. For a slab (dim=2) that means a + b are wrapped while the vacuum direction c stays open — an adsorbate that climbs above the surface keeps climbing in the scene instead of jumping back to the bottom of the cell. The wrap is naive modulo-1 on fractional coords; frame-to-frame continuity is not anchored across cell crossings (atoms can flip from one boundary to the opposite mid-animation in pathological cases — chemistry NEBs typically stay within the central cell, so this is rare in practice). Variable-cell paths ([n_frames, 3, 3] lattices) re-emit the box per frame, so the cell animates with the geometry.

vibrations

Mode-selector dropdown to pick the normal mode (sorted by frequency); the structure animates with the displacement vector applied sinusoidally. Frequencies are in cm^-1.

atom_properties

Tabular display of mulliken_charge / loewdin_charge / spin_population (whichever the producer wrote). Color-coded per-atom highlighting in the 3D viewport.

wavefunction.gto

Browse the molecular orbitals as a list (with energies and occupations) and click any MO to render its isosurface. vibe-view resamples the orbital from the embedded GTO basis + MO coefficients on a grid of its own choosing, so you can inspect any MO without the producer having to pre-evaluate it.

scf_history

Two-pane chart: energy vs iteration (top) and |DIIS error| vs iteration (bottom). Hover any iteration to see the exact numbers.

structure.symmetry

Compact info panel with the spglib output: space group name, Hall number, international symbol, Wyckoff positions, equivalent atoms.

citations

The embedded BibTeX bundle is rendered as a copy-paste-friendly block. Pairs with the citations user guide: the viewer shows you what to cite, the producer wrote the database entries.

viewer_defaults: producer-side hints

The producer can suggest defaults to the viewer in the manifest under viewer_defaults. vibe-view picks these up on load and applies them before rendering anything.

write_qvf(
    "water.qvf",
    ...,
    viewer_defaults={
        "auto_open": ["density"],          # activate the density section by default
        "density": {                       # per-section render hints
            "isovalue": 0.04,
            "colormap": "viridis",
            "opacity": 0.55,
        },
        "bookmarks": [                     # camera bookmarks
            {"name": "front",  "camera": {...}},
            {"name": "above",  "camera": {...}},
        ],
    },
)

Hints are non-binding: the user can override any of them through the UI, and unknown viewer_defaults fields are ignored rather than rejected.

Supported kinds

The viewer’s renderer registry lives at vibe-view/src/vibeview/kinds.py::SUPPORTED_KINDS. The full writer / viewer support matrix is in the QVF design doc § 1.4. Anything not in the registry is classified as “skipped, unsupported” by the viewer and listed in the banner. The viewer never aborts on an unknown kind; it just leaves that sidebar entry unclickable.

If you write a custom kind under the x_<vendor>.* namespace, the viewer reports it as “skipped, vendor namespace” with the vendor name extracted. To get a kind rendered, add it to SUPPORTED_KINDS and wire a matching renderer under vibe-view/src/vibeview/renderers/.

Common pitfalls

“vibe-view: command not found”

You installed vibeqc but not vibe-view. Install with one of the two routes above (pip install -e '.[viewer-gpu]' or pip install -e vibe-view/). The viewer is a separate package because its dependency footprint (VTK, PyVista, Trame) is large and not every vibe-qc user wants it.

“ManifestValidationError”

The archive’s manifest.json does not satisfy the canonical JSON Schema at python/vibeqc/output/formats/qvf_manifest.schema.json. This usually means the producer is older than the viewer or vice versa. Both sides load the same schema (vibe-view bundles it as a symlink), so a sha256-identity test pins them; if the archive was produced by a third party, ask them to validate with validate_qvf before sharing.

“SHA256MismatchError”

A binary payload in the archive has a different sha256 than the manifest claims. Usually means the archive was edited after writing (zip-shuffled, copied truncated, …). The viewer reports this per-section: other sections still load.

“the browser opened but the page is blank”

Trame uses Vue 3 + WebSocket. A very strict ad-blocker or corporate proxy can break the WebSocket handshake. Either allow the page in the blocker, or launch with --host 0.0.0.0 and open the URL by hand from a different browser profile.

Firefox on macOS 15+ “Unable to connect to 127.0.0.1”

macOS 15 (Sequoia) added a system-level Local Network permission gate that is denied to Firefox by default. The symptom is unmistakable: Safari and Chrome connect to http://127.0.0.1:8080 fine, but Firefox shows “Unable to connect” with a hint about Local Network permissions in macOS Privacy & Security. Two fixes:

  • Grant Firefox the permission. System Settings → Privacy & Security → Local Network → toggle Firefox on. Refresh the page. (vibe-view does not need elevated permissions itself; only the browser does.)

  • Or use a different browser. Safari and Chrome are unaffected because they were grandfathered into the permission system. Open http://127.0.0.1:8080 there while the vibe-view server keeps running in the terminal.

vibe-view itself binds to 127.0.0.1 only by default; nothing on the server side needs reconfiguring. If you launched with --host 0.0.0.0 for remote access the same Local Network gate applies to any LAN browser hitting the box, not just Firefox.

“vibe-view server starting on …” printed but the browser cannot connect for a few seconds

vibe-view announces “ready” only once uvicorn has actually bound the TCP port (the watcher polls the listener in a background thread, so the “ready” line and the automatic browser-open happen post-bind). If you set --no-browser and open the URL by hand immediately after launching, you may still race the bind on slow machines; refresh once and you should connect. If “ready” never appears, see the next entry.

“vibe-view: warning, server did not bind within 10s”

uvicorn entered the asyncio main loop but never made it to binding the TCP socket. Two common causes:

  • Port already in use. Another process is bound to the same port (a stale vibe-view, an aborted SSH tunnel, Docker Desktop, …). Pick a different port with --port 9876 and retry. To find the offender:

    lsof -nP -iTCP:8080 -sTCP:LISTEN
    
  • uvicorn import-time failure. A missing dependency in the venv (the historical case was the now-fixed missing uvicorn declaration in vibe-view’s pyproject; reinstall if you see a ModuleNotFoundError in the stderr just before the warning).

Large MO sets making the sidebar slow

vibe-view loads MO metadata eagerly but MO .dat blobs lazily. If you wrote dozens of orbitals as separate volume.orbital sections (one per occupied MO), the sidebar tree is still fast because the binaries are not read until a click. Producers with many orbitals should prefer one wavefunction.gto section over N volume.orbital sections; the viewer resamples MOs on demand.

Programmatic API

vibe-view’s API is intentionally minimal. Most users want launch_qvf:

from vibeview import launch_qvf, QVFReader, QVFOpenError

# One-line launch
launch_qvf("water.qvf")

# In-memory archive (no temp file). Pair with vibeqc.output.qvf_bytes
# for an end-to-end producer → viewer hand-off that never touches disk.
from vibeqc.output import qvf_bytes
data = qvf_bytes(plan, molecule=mol, result=scf_result, mo_data=mos)
launch_qvf(data)

# Inspect first, then launch
try:
    reader = QVFReader("water.qvf")
except QVFOpenError as e:
    print(f"bad archive: {e}")
else:
    print(reader.sections)             # list of Section objects
    print(reader.source.version)       # producer version
    launch_qvf(reader)                 # reuses the open reader

QVFReader accepts a path, raw zip bytes, or any seekable binary file-like (BytesIO, opened file). It validates the manifest at construction time and lazy-loads section payloads on demand.

The full reading-side API for callers who want to consume .qvf archives outside vibe-view is in the QVF consumer reference.

See also