Python Bridge

extensions/python-bridge exposes the services layer via PyO3.

7.1 Installation

See §1.3 Python Package Installation for wheel installation instructions and from-source build steps. Quick verification:

python -c "import orbitron; print(orbitron.Orbitron().load('fixtures/benzene.xyz').atom_count())"

7.2 Usage overview

Atom ids are 1-based, as they are in the CLI and the viewer. distance(scene, 0, 1) raises ValueError: Atom 0 not found.

import orbitron

orb = orbitron.Orbitron(data_root="/scratch/user/runs")

# Inspect a large local source without parsing it. A block verdict is returned
# as data so a notebook can decide what to do without an interactive prompt.
resources = orb.resources("benzene.out")
print(resources["report"]["source"]["projected_host_bytes"])
print(resources["verdict"]["band"])

scene = orb.load("benzene.out")  # path is relative to the data root

print("Atoms:", scene.atom_count())
print("Bonds:", scene.bond_count())
print("First atom Z:", scene.atomic_numbers()[0])
print("Distance 1-2:", orb.distance(scene, 1, 2))  # atom ids are 1-based

# Geometry analysis (same output as CLI)
summary = orb.analyze_geometry(scene)
print(summary["atoms"], "atoms", summary["warnings"])

# Render a PNG headlessly
orb.render(scene, "benzene.png", width=1920, height=1080)

# Grab an RGBA frame for embedding in another UI
frame = orb.render_frame(scene, width=1024, height=768)
rgba = frame.as_bytes()             # copy as Python bytes
mv = frame.as_memoryview()          # memoryview over a copy of the same bytes

# Jupyter display helper (requires pillow)
from PIL import Image
from IPython.display import display

img = Image.frombytes("RGBA", (frame.width, frame.height), rgba)
display(img)

# Load trajectories (optimization/MD logs)
trajectory = orb.load_trajectory("path/to/trajectory.log")
print("Frames:", trajectory.frame_count())
last_frame = trajectory.last_frame()
for index in range(trajectory.frame_count()):
    # A variable-cell trajectory keeps the cell attached to each frame scene.
    print(index, trajectory.frame(index).unit_cell())

# A GROMACS XTC holds coordinates only, so it needs the structure it belongs to.
# The atoms must match the topology's; `confirm_atom_order=True` accepts a pairing
# whose coordinates do not match, with a UserWarning. Both apply to XTC only.
md = orb.load_trajectory("md.xtc", topology="structure.gro")

# Structured export for custom dashboards
scene_data = scene.to_dict()
print(scene_data["atoms"][0]["position"])

# Molecular results and calculation provenance
print(scene.geometry_provenance())  # {"role": "converged_final", "source": "step 8 of 8, ..."}
print(scene.loader_warnings())      # [{"code": ..., "message": ...}], what the loader skipped
print(scene.program())              # {"name": "ORCA", "version": "6.1.1"}, or None if the file names no program
print(scene.dipole_moment())
print(scene.excited_states())
print(scene.magnetic_anisotropy())  # OpenMolcas SINGLE_ANISO: g tensors, crystal field, chi T
print(orb.analyze_bond_orders(scene))
print(orb.analyze_sterics(scene, z_atoms=[2], xz_atoms=[3]))  # %V_bur by quadrant and octant
print(orb.task_summaries("benzene.out"))

# Ordered PDB/mmCIF residue records, including unresolved declared residues.
# atom_numbers uses the same one-based numbering as the rest of Orbitron.
for residue in scene.residues():
    print(
        residue["site"],
        residue["name"],
        residue["component_name"],
        residue["atom_numbers"],
    )

# Serialize a SceneGraph for wasm/Jupyter widgets
scene_bytes = scene.to_bytes()

# Export a single self-contained, interactive HTML viewer (embeds the WASM viewer
# + scene as one file — open in any browser, or drop into a slide deck / webpage)
import orbitron as orb
orb.export_html(scene, "benzene.html", camera=None, appearance="presentation")

An NWChem .out prints only a truncated MO table, so its orbitals can be drawn only with the coefficients in the job’s .movecs. load(path, movecs=None) attaches the sibling <stem>.movecs automatically when there is one; pass movecs="run.movecs" to name the file. A named file that belongs to another job or another SCF raises ValueError. A sibling that does not pair is left off with a UserWarning, since the structure is still good without it.

For large scenes, avoid building one Python object per coordinate. The packed buffer methods make one copy at the Rust/Python boundary and can be wrapped by NumPy without another copy:

import numpy as np

positions = np.frombuffer(scene.atom_positions_buffer(), dtype="<f4").reshape(-1, 3)
atomic_numbers = np.frombuffer(scene.atomic_numbers_buffer(), dtype=np.uint8)

atom_positions() and atomic_numbers() remain convenient for small scenes. to_bytes() serializes the complete scene for the WASM component, while to_memoryview() exposes those serialized bytes as a Python memory view.

A Trajectory has the equivalent for every frame at once. positions_buffer() returns all frame coordinates as one little-endian float32 buffer, shape (frames, atoms, 3) row-major, so a multi-frame run needs one call rather than one Python object per frame. It requires a constant atom count across frames and raises otherwise.

traj = orb.load_trajectory("opt.log")
coords = np.frombuffer(traj.positions_buffer(), dtype="<f4").reshape(
    traj.frame_count(), -1, 3
)

The other Trajectory accessors return one entry per frame in path order, so they line up with each other and with the buffer’s first axis: frame_metadata(), frame_energies(), and frame_steps(). reaction_coordinates() returns the coordinate values for a reaction path and None for a plain trajectory, and reaction_path() describes the path itself (points, transition state, coordinate unit, forward and reverse barriers, and reaction energy in Hartree), also None when the trajectory is not one. storage_kind() reports "eager", "indexed", or "packed" for the backing frame store.

resources(path, mode="indexed-trajectory") reads file metadata and at most the first 64 KiB. It returns the same source report, host-memory snapshot, policy verdict, and configured hard cap as the CLI preflight. Use mode="trajectory" when the planned operation retains every frame, or mode="scene" for a static open. A "block" verdict does not raise MemoryError; load remains the operation that applies the Python warning or hard-cap policy. Missing files, directories, and unknown mode names raise an exception before parsing starts.

Build a scene from supported SMILES and declare its intended electronic state:

scene = orbitron.Scene.from_smiles("[NH3+]CC(=O)[O-]")
print(scene.molecular_electronic_state())

scene.set_molecular_electronic_state(total_charge=0, spin_multiplicity=1)
print(scene.molecular_electronic_state()["electron_count"])

Scene.from_smiles(smiles, generate_3d=True, seed=0x005A11E5) uses a deterministic 3D embedding by default. The same supported subset and refusal rules as orbitron from-smiles apply. set_molecular_electronic_state rejects multiplicity zero and periodic scenes. It changes builder intent without altering electronic-structure evidence parsed from a calculation.

Scene.residues() returns an empty list for structures without biological identity. PDB and mmCIF records keep author and label chain/sequence fields, insertion codes, alternate locations, polymer classification, one-letter codes, unresolved-residue markers, and the source atoms for each residue. mmCIF records also include label_component_id, component_type, component_name, component_formula, component_formula_weight, and parent_component_id when the source supplies _chem_comp definitions.

Available helpers mirror the CLI modules: analyze_orbitals, analyze_populations, analyze_bond_orders, analyze_vibrations, analyze_band_structure, analyze_density_of_states, export, bounding_box. Analysis Results maps these records to the desktop, TUI, and CLI views.

Six more on Orbitron are easy to miss because nothing above reaches for them:

Method What it does
analyze_nbo(scene) Natural Bond Orbital populations, when the run carries them, and under evidence["second_order"] the donor-acceptor E(2) table (strongest first, one threshold per spin table)
atom_frontier_contributions(scene, atom_index, frontier=3) How much one atom contributes to each frontier orbital
orbital_atom_contributions(scene, frontier=3, top_atoms=5) The other direction: the dominant atoms of each frontier orbital
render_orbital(...) An MO isosurface straight to a PNG, evaluated from the scene’s basis set. Needs a working GPU adapter
vibration_frames(...) Geometries sampling one mode through a full period, ready to render
load_json(json) A scene from an Orbitron JSON string, envelope or bare snapshot

And several module-level pieces beyond the loader:

# `number` counts from 1, as the CLI's `animate --mode` does; `mode=6` is the
# same bend by its 0-based index.
orbitron.animation.write_vibration_gif(orb, "h2o-freq.out", "bend.gif", number=7)
orbitron.animation.write_gif(frames, "opt.gif", fps=20)   # frames from render_frame
cam = orbitron.animation.fixed_camera(scenes, margin=3.0)  # one still camera for every frame
orb.render_frame(scenes[0], camera=cam)                    # (what write_vibration_gif does)

svg = orbitron.diagram_svg(scene, style="skeletal")       # 2D structure diagram
orbitron.diagram_quality(scene)                            # its crowding, without drawing it
report = orbitron.diagram(scene)                           # drawing + measured labels, one layout
report["quality"]["labels"]["legible"]                     # no overlap, collision or clipping

orbitron.configure(viewer_js_url=my_cdn_url)               # pin the inline viewer's assets
orbitron.configure(viewer_height="60vh")                   # CSS height of the inline viewer, default "420px"
orbitron.reset_configure()

The Orbitron object also measures and locates: angle(scene, a, b, c) in degrees, dihedral(scene, a, b, c, d), center_of_mass(scene) and geometric_center(scene) (a centroid), all with 1-based atom ids.

orbitron.viewer_build_identity() returns the version, source_revision and bundle_sha256 of the staged viewer bundle, after checking it against its manifest (it raises ViewerAssetsError on a mismatch), so a cache of rendered pages can be keyed on it. The same check runs from a shell as python -m orbitron.check_assets, which prints the identity and exits 0, or prints what does not match and exits 1.

orbitron.animation needs Pillow and nothing else does, which is why it is a Python module rather than another Rust binding. The viewer widgets (ViewerWidget, ViewerDomWidget), the delta-envelope builders they push updates with, and orbitron.serve are covered in Web Viewer Embedding.

Chemical identifiers — generate InChI / InChIKey / SMILES / molecular formula, including for metal complexes and coordination compounds where RDKit-style perception fails:

scene = orb.load("cisplatin.xyz")
orb.inchi(scene)      # 'InChI=1S/...'
orb.inchikey(scene)   # 27-character hashed key
orb.smiles(scene)     # SMILES (organometallic sandwich rings degrade to chains)
orb.formula(scene)    # 'Cl2H4N2Pt' (Hill notation)

info = orb.identify(scene)   # all four, with provenance and diagnostics
info["failures"]             # [] or [{"identifier": "smiles", "code": ..., "message": ...}]
info["warnings"]             # what the InChI and SMILES writers raised
info["metal_states"]         # per metal: the state the identifiers carry, the reported state, where it came from
info["identifier_revision"]  # bumped when the same structure can give a new identifier
info["producer"]             # {"name": "orbitron", "version": ..., "commit": ...}

The string helpers return only the string. identify returns the same report as orbitron identify --json (one serialisation behind both; the CLI adds its own loading diagnostics to warnings under the sources loader and cli), including the options each identifier was written with, and says why one could not be written (an extended solid has no molecular identifier; an exotic species may have an InChI and no SMILES) without losing the others. Store identifier_revision with an identifier: it is bumped whenever a change to the ionic model or the writers can move the identifier of the same structure, which is the cue to re-check what was stored.

The identifiers write each metal at the ionic model’s state. Where the reported oxidation state is another one (a bond-valence sum or the coordination number replaced it), the ligands are read again with their charges allocated to give it and metal_states[...]["reconciled"] says so. Where no such reading exists, metal_states names both states and a warning with the code identifier_state_differs says the identifier is the weaker of the two. Against the CSD names the two differed for 2.4% of 64,104 complexes before this reading; it reconciles 933 of the 1,507, and the reported state was the right one for 1,193 of them and the identifier’s for 90.

A coordinate file cannot say which atom carries a charge or what a metal’s oxidation state is, so a state can also be stated. orb.with_oxidation_states(scene, {atom_id: state}) returns a copy in which that metal’s state is given (the atom of a metal_states entry is the id). The ligands are then read toward it where they can reach it, and metal_states keeps the stated state apart from the inferred one: supplied_state and inferred_state, with conflict true and a supplied_state_conflict warning where the structure disagrees (reported whether or not the identifier could be made to carry the stated state). A stated state no reading of the ligands reaches is not forced: the identifier keeps the inferred state, differs is true and both warnings appear. bond_valence_state is the consistent bond-valence sum’s state, shown for comparison; where it differs from the identifier’s state the identifier’s has been right 3,271 times to the sum’s 425 (3,758 of 64,104 CSD complexes, 2026-10-23), so a difference alone is data and does not set ambiguous. ambiguous is true, with alternatives, when no state was stated and a metal is bound to both ends of an O-O unit: a peroxide and two oxide donors move the metal by two, and the coordinates do not say which it is. No metal is assumed to take any state.

Each metal_states entry also has confidence (high, moderate, low, uncalibrated, as_stated or None) and cautions, a list of {kind, message, ligands}: the grade and the reasons orbitron analyze metal-sites gives for the same state. Two of the cautions mean the state follows from a reading the coordinates do not settle even when no alternative state exists, and they also appear in warnings (source oxidation_state) under their own code: cluster_charge, where a boron cage’s charge came from the cluster it forms with the metal (each metal fragment counted as two skeletal electrons, the edge count consistent with the class but not a structure determination), and identical_ligands_differ, where ligands with the same atoms and bonding were given different charges, which is suspicious but not proof of an error (ligand-based mixed valence is a real exception). Neither sets confidence. ligands names the ligands a caution is about ([{"atoms": [117, 118]}] for a non-innocent ligand, one entry with a charge for each ligand of an identical_ligands_differ set, empty for a caution about the whole site): atom ids are the scene’s own, counted from 1 as metal_states[].atom is, so two cautions with the same words stay distinguishable. The identifiers and identifier_revision are unchanged by them.

Each entry also has calibration, how the grade was reached, the same object orbitron analyze metal-sites gives as trust.calibration: domain and its note (benchmark, organometallic, fixed_state, or why no benchmark describes the site: no_declared_charge, periodic, metal_carbon_bond, polynuclear, several_metals, uncovered_element) and, where it does, the accuracy, complexes and level of the group that answered, the accuracy’s 95% interval, and measured, the sentence the text report prints under the grade. level names the parts of the site that group matched, so a lookup over the element, source and bonds alone can be told from one that also matched the donor kinds. It is None for a stated state, and it is benchmark coverage, not a probability that the species’ state is right:

metal = orb.identify(scene)["metal_states"][0]
metal["confidence"], metal["calibration"]["domain"], metal["calibration"]["complexes"]

orb.diagram(scene) draws the same reading identify writes, not the bare connectivity of the file: formal charges are written, a metal complex is drawn in the ionic model with its donor bonds dashed, hydrogen counts are subscripts and charges are superscripts. A ring or chain bound by its face (a cyclopentadienyl, an arene, a diene or an alkene) is drawn with one dashed bond from the metal to its centre rather than one to each of its atoms; the bonds themselves are unchanged, so a molfile written from the diagram keeps one per atom, and bond_crossings in the quality counts the bonds as drawn. The result names what was drawn in interpretation (as_supplied, declared_charge or ionic_model), carries a note that says so when it is an inference, and counts the drawn bonds by kind (single, double, triple, aromatic, donor). A file that states its own bond orders (a molfile, an SDF) is drawn exactly as given, so a reviewed graph keeps its terminal M=O and its C(=O)-OH. interpretation="supplied" draws the loaded atoms and bonds untouched. A reading from coordinates stays provisional; the drawing says so rather than presenting a guess as a fact.

The layout engine judges a drawing by how close atoms are, which cannot see how wide a label is: OH2 is three characters and Ce3+ four, so two donors a bond apart on one horizontal clear each other as points and overlap as text. polish_labels=True (the default) closes that gap for a coordination complex. It turns each ligand rigidly about its metal, and each branch about the atom it hangs on, in 15 degree steps, and reflects it across its anchor bond where that cannot change what the drawing says (no wedge, hash or cis/trans mark on it, and a single bond to flip about), reflects the backbone between two donors (the carbons of a chelate ring) across the line joining them, and spins a ligand held by one atom about it. It slides a ligand out along the line from the metal to the centre of what it is bound through (a lone donor, a face-bound ring, or both), a donor to at most 2.5 bonds from its metal and a ring’s centre to at most 3 (an ordinary bond a slide lengthens stays within 1.6), moves the metal alone by up to half a bond with every donor bond kept within that range, and moves a fragment with no metal (a solvent molecule, a counter-ion) whole, half a bond or a bond at a time and never past the drawing’s bounds. A move is kept only while it lowers the label overlaps and bond collisions that quality["labels"] counts and adds no bond crossing and crowds no atom. A bounded, seeded random walk, which mostly moves a group holding an atom that a violation involves and is run from four seeds with the best kept, takes over where the greedy moves stall, so the result is deterministic. Bond lengths inside a ligand and every ring survive, a drawing that is already clear is returned unchanged, and a molecule with no metal, the orbitron style, or one over 150 atoms is not touched. label_polish in the result reports the moves and the costs before and after (or None). The label boxes are what a browser draws, not what a capital’s outline would need: each character is as wide as the widest of Inter, Liberation Sans, Arial, Helvetica and DejaVu Sans makes it, a box is the height of a browser’s line box, and a subscript or superscript reaches further down or up. Chromium’s text bounds for the cerium EDDA graph at 280 x 220 overlapped in three places while the old, tighter boxes called it legible; over 1,400 drawings (700 tmQM complexes at two sizes) no drawing the scorer calls clear has overlapping text boxes in Chromium. Against 4,466 tmQM complexes at 512 x 512 the share that pass the label gate rose from 20% to 64% with the first polish, to 71% with face-bound rings drawn as one bond and slid off their metals (2026-10-06), and to 88% (3,933) with the metal, ligand, reflection and wider-reach moves: overlapping pairs 9,138 to 766, label-bond collisions 16,614 to 1,950, crossings 15,957 to 9,431 as drawn and the crowded fraction 0.153 to 0.102. The median drawing takes 29 ms, one in ten over 200 ms and the slowest 0.9 s. Where turning the ligands is not enough, the polish tries each ligand laid out on its own (a chelate ring that closes through the metal fixes a ligand’s shape in the full layout, and turning a rigid body cannot undo two bodies sitting on one another): in nine layouts of the ligands without their metal (several weightings of 3D distances against crowding, each ligand projected from its own 3D geometry, and each seen from the metal), each set round the metal in several arrangements, keeping the one that leaves the fewest labels in the way, provided it draws no more crossings and crowds no more atoms than the layout did. Against 4,466 tmQM complexes at 512 x 512 that takes the label gate, at the usual text size (label_scale=1.0), from 3,933 passes (88%) to 4,284 (96%): overlapping pairs 766 to 209, label-bond collisions 1,950 to 464, crossings as drawn 9,429 to 8,353, crowded fraction 0.102 to 0.088, and no drawing that passed before fails now. The median drawing takes 28 ms, one in ten about 280 ms, one in a hundred about 1.7 s. A ligand with a centre and three donor arms (a tris(pyrazolyl)borate, a tris(pyridylmethyl)amine, a tripodal phosphine) is the one shape no rigid arrangement can draw, since a rigid ligand points a single donor at the metal and the other two dashed bonds run through its body, and 30% of tmQM’s scorpionates failed the gate against under 2% of everything else. Its arms are set one at a time instead, each a rigid body pivoted on the centre and turned about its attachment atom, at the directions a search over bonds crossing, labels with a bond through them, arms on one another and long dashed bonds scores best. Tried after the other arrangements, and kept on the same terms, it takes the 4,284 to 4,338 (97.1%) at the usual size: overlapping pairs 209 to 158, label-bond collisions 464 to 317, crossings as drawn 8,353 to 8,129, and no drawing that passed before fails now. Last, where no arrangement of whole ligands clears the labels, the donors are spread evenly round the metal in their order, each a bond from it, and the ligands bent to reach them: a chelate ring gives at the metal while a ligand’s own rings keep most of their shape, and the drawing is kept on the same terms. A wrapped polydentate ligand needs it: drawn as regular polygons, a nickel’s six-membered chelate ring and two five-membered ones take 336 degrees at the metal and put an N2S2 ligand’s two sulfurs on one another (tmQM QALYUB). And a boron bonded to two or more borons, a vertex of a borane or carborane cage, is drawn as an unlabelled corner, as such cages are drawn, its charge still written where it has one; a flattened cage’s ten labels sat on one another and on its crossing bonds whatever the layout. With both (2026-10-17) 4,363 pass at the usual size (97.7%). polish_labels=False gives the engine’s own layout.

label_scale (on diagram, diagram_svg and diagram_quality) sets the size of the element labels. The default, "auto", tries 1.0, 0.9, 0.8, 0.7 and 0.6 of the usual half-bond size and keeps the largest at which the polish leaves no overlap or bond through a label, never text under 9 px and never a drawing with more bond crossings than full-size text gives; where the usual size is clean it is the one drawn, as it is for 97% of the tmQM complexes. A number from 0.3 to 2.0 is used as given after the frame’s cap, and 1.0 keeps every drawing at the usual size, which is what to ask for when figures from one set must have text of one size. The result’s label_scale says what was used. Over the 4,466 tmQM complexes at 512 x 512 the default passes the label gate for 4,432 (99.2%, 4,415 before the spread donors and unlabelled cage vertices) against 4,363 (97.7%) at 1.0, shrinking the text on 83 of them (by 0.9, 0.8, 0.7 or 0.6, never under 9 px). Of the 34 that remain, 29 are metals with six or more donors and four are polyhedral cages (three carboranes and a silsesquioxane), and 20 fail by a single bond through a label. Drawings made with a reduced size have text of a different size from the rest of a set.

orb.relax(scene, frozen=[...], restraints=[...], max_iterations=400) runs the core’s constrained UFF minimiser (the one behind Relax Geometry) on a molecule or complex and returns a dict whose scene is a new scene: atom ids, order and bonds are the input’s, and only positions change. Nothing is perceived; the bond graph and formal charges that type the atoms are the scene’s, or the bonds ((id, id) or (id, id, "single" | "double" | "triple" | "aromatic" | "dative")) and formal_charges ({id: charge}) you give. UFF reads a formal charge only on a metal, where it picks the tabulated metal-donor distance for that oxidation state, so give a metal’s state as formal_charges={metal_id: state}. frozen atoms cannot move but still contribute their bonds, angles and contacts; restraints are (id, (x, y, z), stiffness) tethers in kcal/mol/A^2. The result reports method (UFF), energy_units (kcal/mol), both energies, iterations, converged and termination (converged, iteration_limit or other: a capped run is lower in energy and not necessarily a minimum), max_displacement and frozen_max_displacement (zero if the freeze held), and graph (scene or supplied). A periodic scene, an unknown atom id, everything frozen, or an element UFF has no parameters for raises ValueError, naming the element.

Periodic datasets (VASP and Quantum ESPRESSO band/DOS) are exposed through the scene when present:

periodic = scene.periodic_electronic_structure()
if periodic:
    print(periodic["fermi_energy_ev"])
    print(periodic["band_structure"]["spin_channels"])
    print(periodic["density_of_states"]["energies_ev"][:5])

    # Fundamental gap, computed at load time. None for a metal, or when the
    # run has no k-point coordinates to report the extrema at.
    gap = periodic["band_gap"]
    if gap:
        kind = "direct" if gap["is_direct"] else "indirect"
        print(f"{gap['value_ev']:.3f} eV ({kind})")
        print("VBM at", gap["valence_kpoint"], "CBM at", gap["conduction_kpoint"])

    # Site- and orbital-resolved DOS, from a VASP DOSCAR written with
    # LORBIT >= 10. Indexed spin -> atom -> orbital -> energy.
    pdos = periodic["projected_dos"]
    if pdos:
        print(pdos["orbitals"])          # ['s', 'py', 'pz', 'px', 'dxy', ...]
        print(len(pdos["per_atom"][0]))  # atoms in the decomposition

    # Some codes report the projections as discrete states rather than a
    # curve — one entry per orbital, with weights and no width. CP2K's `.pdos`
    # is the case Orbitron reads.
    states = periodic["projected_states"]
    if states:
        print([group["label"] for group in states["groups"]])   # ['O', 'H']
        first = states["groups"][0]["states"][0][0]
        print(first["energy_ev"], first["occupation"], first["weights"])

    zone = scene.brillouin_zone()
    if zone:
        print(zone["zone"]["reciprocal_basis_inv_angstrom"])
        print(len(zone["zone"]["faces"]), "first-zone faces")

Turn those states into a curve with an explicit width. The result has the same shape as projected_dos, plus broadening_ev recording what was used and group_labels naming the rows (CP2K projects onto element kinds, not sites):

dos = scene.broaden_projected_states(width_ev=0.2, shape="gaussian", step_ev=0.01)
print(dos["group_labels"], dos["broadening_ev"])

The width is an argument and not something the loader picks, because it changes the plot: too narrow is a comb of spikes, too wide merges two states into a peak that is neither of them. Each state carries unit area, so the integral counts states no matter the width — widening only lowers the peaks.

analyze_metadynamics(path) reads a CP2K metadynamics run — its collective variables, their series against time, and the free-energy surface its hills add up to. It takes a path rather than a scene, because the companion files describe the run and not the structure.

result = orb.analyze_metadynamics("project.out", points=120)
print([cv["kind"] for cv in result["collective_variables"]])   # ['BOND', 'ANGLE']
print(result["surface"]["barrier_kj_per_mol"], result["surface"]["barrier_at_edge"])

# The grid comes back flat with its shape, last axis varying fastest.
import numpy as np
surface = result["surface"]
grid = np.array(surface["free_energy_hartree"]).reshape(surface["shape"])

surface["agreement_hartree"] is the largest disagreement between this reconstruction and the bias the run itself recorded step by step; agreement at the precision CP2K prints (about 1e-5 Ha) means the sum reproduces the code that produced it. barrier_at_edge says the highest point is where the sampling ran out rather than a barrier the run crossed. The CLI’s --figure has no counterpart here: the bridge builds without a plotting dependency, and a caller already in Python has matplotlib.

band_structure["sampling"] reports "path" or "mesh" when the run says which. Eigenvalues from a "mesh" are stored in grid order, so plotting them against k-point position is not a band structure — see the CLI guide for the full explanation.

Pass include_projections=True to request orbital/site projection weights (can be large).

  • Hands-on examples (repo root):
    • extensions/python-bridge/examples/api_smoke_test.py — CLI-style smoke test that exercises loading, measurements, exports, rendering, and analysis helpers.
    • extensions/python-bridge/examples/api_quickstart.ipynb — step-by-step notebook for geometry basics.
    • extensions/python-bridge/examples/api_analysis_walkthrough.ipynb — notebook showcasing orbital, population, and vibrational summaries.
    • extensions/python-bridge/examples/api_trajectory_view.ipynb — minimal trajectory preview with in-memory render frames.
    • extensions/python-bridge/examples/api_viewer_widget.ipynb — Jupyter widget demo with wasm viewer + delta updates.
    • extensions/python-bridge/examples/orbitron_tutorial.ipynb — end-to-end tutorial notebook for the Python bridge.
    • viewer/wasm/scripts/generate_scene_bin.py — dump a SceneGraph to a binary file for the web viewer.

7.2.1 Several files as one calculation

A job resubmitted after walltime, or unrelated runs looked at together. The task list spans every file and one member is shown; see Several Files, One Calculation for what continues what.

study = orbitron.open_study(["jobA.out", "jobB.out"])

for task in study.tasks:
    where = study.members[task["source"]]["path"]
    # `trustworthy` is False when the program disowned the task: the energy is
    # the last value it reached, not an answer.
    print(task["label"], task["outcome"], task["trustworthy"], "from", where)

for warning in study.warnings:
    print("!", warning)

study.show(0)          # display the first member instead of the last
scene = study.scene()  # its structure, exactly as `orb.load` would give it

An NWChem restart without an echo prints no coordinates at all, so orb.load raises on it and open_study reads it — the member inherits its predecessor’s geometry and the document records that it did.

7.2.2 Molecular surfaces and accessible area

Scene.molecular_surface() returns the mesh and what it turned out to be; Scene.accessible_area() returns numbers and never builds geometry. Both release the GIL for the compute, so a surface on a protein does not stall other threads.

scene = orbitron.Orbitron().load("1ubq.pdb")

surface = scene.molecular_surface(kind="excluded", selection="not resname HOH")
surface["vertices"], surface["normals"], surface["triangles"]   # the mesh
surface["area"], surface["volume"], surface["components"]       # what it is
surface["integrated_area"]                                      # None: excluded
surface["spacing_requested"], surface["spacing_used"]           # what it cost

areas = scene.accessible_area(selection="not resname HOH")
areas["total"]                    # 4845.2 Ų for this file
dict(zip(areas["atom_ids"], areas["areas"]))

area is the mesh’s own, and a mesh cuts across the creases where atoms’ spheres meet, so it runs a few percent short and closes the gap as the grid refines. For the accessible and van der Waals surfaces, unions of spheres, integrated_area is the area integrated over the spheres as accessible_area measures it, independent of the grid: per cell it agrees with Zeo++ to within 1% on two zeolites, where the mesh at 0.2 Å is 1.3 to 6.6% short. It is None for the excluded surface, which has no such integral. The excluded surface has no creases, and its mesh’s area is within 0.5% of the closed form for one atom and for two on a 0.2 Å grid, coarser than the default.

Two returned fields are the ones to act on, because neither is visible in a picture of the result. spacing_used above spacing_requested means the grid was coarsened to stay inside max_voxels, and the surface is correspondingly less accurate; raise max_voxels and accept the memory if the figure needs it. components above one means the surface is not a single closed shell: a cavity large enough to hold the probe, or a fragment the selection cut loose.

selection removes the rest of the structure rather than leaving it out of the result. That is what makes "not resname HOH" mean “the protein as if the crystallographic waters were not there” — and it means a per-residue area computed from a selection is that residue in isolation, not in its structure.

An element the chosen radius table does not cover raises rather than substituting a stand-in, because a stand-in produces a surface that looks exactly like a correct one. radii="bondi" covers 38 elements; the default "alvarez" covers 93 including the transition metals.

radius_policy defaults to "ionic_when_known" on both calls, and it is the argument most likely to change a number without announcing itself. Under it a metal whose charge and coordination number the structure records takes its Shannon ionic radius instead of a van der Waals one — Zn(II) at CN 4 is 0.60 Å against Alvarez’s 2.01 — which is the right radius for an exposed cation and the wrong one for reproducing a published area, since published areas are computed with van der Waals radii throughout. Pass radius_policy="vdw" for a comparison. Only metals are affected; the CLI guide’s §4.3.3 has the reasoning and the two-radii cases.

cell decides, for both, whether the scene’s cell applies: "auto" (the default) puts a framework or a slab, whose bonds run through the cell, under it, and leaves a molecular crystal or a protein’s CRYST1 cell alone; "isolated" and "cell" decide it outright. Under the cell molecular_surface cuts the mesh at the cell’s faces, its area is per cell, volume the volume enclosed within the cell and cell_volume the cell’s own, and components is None; void_regions says how the space outside the surface divides, each region’s dimensions (0 for a cavity closed within a cell, 1 a channel, 2 a layer, 3 a network running through the lattice) and its volume per cell, leaving out a region smaller than a cube two voxels on a side, which the grid does not resolve, and is None for a closed surface. For the excluded surface the space is divided where the probe’s centre can go, as for the accessible one. accessible_area counts an atom’s neighbours across the faces. Both return under_cell.

The other two arguments are cost knobs. max_voxels (default 4,000,000) caps the molecular_surface grid — that is the cap spacing_used reports hitting. points (default 960) is how many sample points per atom accessible_area places; fewer is faster and noisier.

7.3 Exporting bundles

export_bundle writes the same bundle orbitron canonical export does, which matters when the notebook is running on a cluster and the data is far larger than what you want to carry home:

info = orb.export_bundle("run.out", "run.orbpack", mo=31)
print(info["source_bytes"], info["bytes"], info["transfer_reduction_percent"])
print(info["sections"])

scene = orb.load("run.orbpack")   # opens like any other file

It takes one source. Packing several files as one study bundle is CLI-only, as are the appearance and figure flags (--appearance, --show, --hide, --group, --camera, --ribbon-opacity) and --no-mo-coefficients — see §6.8 for the full division.

The options it does take match the CLI: with_source, mo, movecs, grid_spacing, grid_padding, scrub, force, and infer_bonds. movecs names the NWChem .movecs that mo is evaluated from (the sibling <stem>.movecs is used when there is one), and grid_spacing and grid_padding override the evaluated grid’s resolution and extent in Å (defaults 0.20 and 4.0). infer_bonds defaults to True, so a source that declares no connectivity gets perceived bonds in the bundle and renders as the viewer would show it; set it to False to keep the bundle a faithful record of the file. A source that states its own complete connection table is never altered either way. The returned byte counts compare a regular source file with the finished bundle. The source count and percentage are None for a directory input because directory metadata is not a count of the files Orbitron read. See Bundles for what goes in by default and why.

To show a bundle in a notebook, use orbitron.view:

import orbitron

orbitron.view("run.orbpack")

orb.load("run.orbpack") gives you a Scene, and a scene displays inline too — but a scene is structure alone, so the bundle’s grids, frames, modes and recorded view are gone by the time it reaches the browser. view hands the container over intact, which is what makes an orbital surface or an optimisation appear in the cell.

7.4 Fonts (WASM widget)

The wasm viewer embeds the Inter Regular font (assets/fonts/Inter-Regular.ttf) for in-scene measurement labels. The font is licensed under SIL OFL 1.1; see assets/fonts/Inter-OFL.txt for the full license text. The same file is embedded by core/diagram-render, so a label drawn by orbitron render and one drawn in the browser are the same typeface.