Services Layer
OrbitronServicescombines the workspace into a unified API. The struct owns:LoaderService: wraps IO pipelines and enforces size limits (with_max_size).AnalyzerService: centralises distance/angle/dihedral and statistics calculations.analysis/orbitals/slice.rs(re-exported fromanalysis/orbitals/mod.rs): the 2D MO/density slice evaluator —SlicePlane,ScalarField2D,mo_slice_from_basis,density_slice_from_basis,sample_volume_slice— backing the desktop’s slice section (inside the Surfaces tab) and the TUI slice.ExporterService: writes scenes and trajectories to disk (XYZ, multi-frame XYZ, PDB, Molfile, VASP POSCAR, and GROMACS GRO viaexport_groinexporter/gro.rs).export_with_formatdispatches by requested format.RendererService: lazily initialised renderer with configurable backend (Backend::Headless,Backend::Gpu).DataSource: trait implemented byLocalDataSource, abstracting file access.
ServicesConfigcontrols the load limits (max_file_size,max_atoms,max_bonds, andmax_memory— the hard cap the capacity gate enforces), verbosity, render backend, and default data root. The CLI propagates command-line flags into this config. Itsenable_gpufield is a deprecated compatibility flag: rendering already tries the GPU and falls back to the CPU where supported, so setrender_backendwhen a method needs an explicit backend, and leaveenable_gpualone.streaming::TrajectoryStreamis a synchronous chunked-frame trait (next_chunk). Only a test mock implements it, and nothing in the viewer uses it.analysis::bond_orderconverts canonical computed pair and three-center bond orders into one summary without changing scene connectivity.analysis::sequence_alignmentis global pairwise alignment: Needleman–Wunsch (S. B. Needleman and C. D. Wunsch, J. Mol. Biol. 48, 443–453, 1970) with Gotoh’s affine gap penalties (O. Gotoh, J. Mol. Biol. 162, 705–708, 1982), which is what keeps it O(mn) rather than O(m²n). It scores on identity rather than a substitution matrix, because its caller is structural: two copies of the same or a closely related protein, where identity separates the sequences well enough and a BLOSUM table would be a second thing to explain. It is global, so terminal gaps are charged; a fragment against a whole protein wants Smith–Waterman, which does not exist here.analysis::superpositionpairs atoms two ways, andSuperpositionAtoms(which atoms are eligible) is a separate axis fromSuperpositionPairing(how an eligible atom finds its partner).Siteneeds the residue key and atom name to agree exactly;Sequencederives the residue correspondence fromsequence_alignmentper chain and pairs by atom name inside each aligned pair. Chains pair by identifier, except that one chain against one chain pairs regardless of lettering. Residues with no one-letter code — ligands, ions, solvent — never enter the alignment, or a run of waters could absorb a gap.periodic::ReciprocalScenere-exports the reciprocal-space model fromorbitron-periodic, keeping the desktop, TUI, CLI, Python bridge, and vector exporters on one first-zone and path-folding implementation.vaspholds the periodic-figure builders, which readPeriodicElectronicStructurerather than anything program-specific — a Quantum ESPRESSO run goes through them unchanged. The PNG/CSV writers (periodic_figures.rs) sit behind the default-onfiguresfeature, which pullsorbitron-plotsand draws the same figuresorbitron plotdoes; the pure arithmetic below does not, so adefault-features = falseconsumer such asviewer/corestill gets it:build_band_curves— one curve per band. This is the band structure;build_band_envelopeismax(E ≤ E_F)/min(E > E_F)per k-point, which steps to the next band down at a Fermi crossing and is discontinuous by construction.build_fat_band_curves/build_fat_band_curves_by_shell— the same curves with a projection weight per point, selected byProjectionSelector { atoms, orbitals }. A bare shell letter expands to that shell’s labels via an explicit table, because VASP spells d(x²−y²) asx2-y2and a “starts with d” rule silently drops one of the five d orbitals.build_pdos_curves— per-element, per-shell DOS curves fromProjectedDensityOfStates.kpath_axis/kpath_ticks— what the x axis actually is (reciprocal distance vs k-point index, since not every reader can produce distances) and the labelled corners to draw.
plotbuilds the data figures fororbitron plotand the desktop’s figure export: each builder turns parsed data into anorbitron_plots::Figureplus the exact values it draws as CSV. It decides what is drawn; drawing isorbitron-plots(§5.9).animationis the frame plan and encoders shared byorbitron animateand the desktop’s animated export:AnimationPlanresolves a mode’s phase, a trajectory frame or a camera orbit angle to a scene and camera per frame, andAnimationWriterwrites a looping GIF or PNG frames. Rendering stays with the caller.
7.1 Using OrbitronServices (Rust API)
use orbitron_services::{OrbitronServices, ServicesConfig, Backend};
fn main() -> anyhow::Result<()> {
// Configure headless services (suitable for CLI tools or batch jobs).
let config = ServicesConfig {
enable_gpu: false,
max_file_size: 2 * 1024 * 1024 * 1024, // 2 GiB cap
max_atoms: 0, // 0 = unlimited
max_bonds: 0, // 0 = unlimited
max_memory: 0, // 0 = unlimited
verbose: true,
render_backend: Backend::Headless,
data_root: Some(std::env::current_dir()?),
};
let services = OrbitronServices::new(config)?;
let scene = services.load("fixtures/benzene.xyz")?;
// Measurements and derived data
// Atom ids are 1-based, matching each format's native numbering.
let distance = services.distance(&scene, 1, 2)?;
let bbox = services.analyzer().bounding_box(&scene)?;
// Export / render
services.export(&scene, "benzene.pdb")?;
services
.renderer()
.render_to_file(&scene, "benzene.png", 1920, 1080, None)?;
Ok(())
}- Prefer the façade methods (
load,distance,export) for convenience. The sub-services stay accessible via getters if you need finer control (services.loader().load_frequencies). - Swap in an alternative data source via
OrbitronServices::with_data_sourceand a customDataSource; the CLI and Python bridge follow this pattern. - Persistent configuration lives in
config.rs.OrbitronConfigresolves the platform-specific config directory viadirectories::ProjectDirs; both the CLI and GUI load it on start and merge it with per-run flags or environment overrides. - Use
renderer_mut()when you need to mutate renderer options (e.g., toggling lighting presets before drawing headless renders). streaming::TrajectoryStreamis a synchronous trait with no production implementor (only a test mock), and the viewer does not use it; don’t build on it expecting viewer playback to follow.
7.2 Studies: several sources read as one calculation
LoaderService::open is the door, and it returns a Study (core/services/src/study/), not a SceneGraph. open(path) and open(&paths) are the same call, and a study of one behaves exactly as load did — that equivalence is the regression the whole design turns on.
Orbitron read one file at a time until 2026-09-07, and a great deal of real work does not arrive that way. Two shapes drove this, and they turned out to be one structure:
- Continuation. An HPC job outlives its walltime and is resubmitted, so one calculation spans several outputs. A real four-file NWChem chain has its geometry optimisation in file 1 and one numerical Hessian restarted across files 2, 3 and 4, with only the last holding the finished modes. Worse, a
restartcontinuation often cannot be parsed alone at all: its geometry lives in the.db, so the output has none. - Composition. Unrelated calculations viewed together — an Fe task, a ligand task and a complex task submitted as separate jobs, or an NWChem run beside a Molpro one. Three tasks in one file and three tasks in three files should look the same, and under this model they do.
Composition is simply the case where nothing continues anything, so there is one container rather than two concepts.
The four types
| Type | What it is |
|---|---|
TaskInstance |
What one source says about one task: its energy, its SCF cycles, its outcome, its line range. This is ProgramTaskSummary plus the member it came from. A task carried a line range but not a file until studies existed, which is exactly what made a calculation spanning several outputs unrepresentable. |
LogicalTask |
The instances that continue one another, plus the joined result. is_continuation() is just instances.len() > 1. Its outcome() is the last instance’s — an earlier one that ran out of walltime does not condemn a task a later one went on to finish. |
StudyMember |
One source: its path, its program, the RunProvenance the run recorded inside the file, and the whole CanonicalDocument it parsed to. |
Study |
members, tasks, active, warnings. |
A study keeps its members rather than merging them into one document. Merging would have to reconcile each program’s own extras block — the per-format logic the shared task layer exists to avoid — and a lossy merge would be silent. Keeping them also keeps the disk cache working as it does: one entry per file, keyed by that file’s contents, so adding a fourth member to a three-member chain re-parses one file.
One member is shown and the task list spans all of them. Study::document(), scene() and trajectory() all read the active member; instances() flattens every task across every member. Nothing merges the structures — two members of a restart chain hold the same molecule at different points, and averaging or concatenating them would produce a geometry that never existed. joined_trajectory(task) is the explicit exception, and only for a task whose join rule is Concatenate. It drops the first frame of each later member when its coordinates repeat the previous member’s last frame, because a restart re-reports the geometry it resumed from. That test is on coordinates, exact equality: measured on a real split water optimisation (io/pipelines/tests/fixtures/nwchem/split-optimisation), part 2’s step 0 matches part 1’s step 2 to all eight printed decimals while their energies differ by 3e-9 Ha, because part 2 re-converged the SCF there.
The join rule belongs to the task kind, not to the program
JoinRule::for_kind (study/join.rs) is keyed on ProgramTaskKind:
Concatenate— optimisations and MD. Successive parts of one walk through configuration space.LastComplete— frequency. A numerical Hessian accumulates finite-difference energies across restarts and only the final instance carries the modes.Last— SCF, single point, gradient, DOS, bands, and anything nobody has written a rule for. Superseding is the safer wrong answer: it shows one result rather than inventing a sequence out of unrelated numbers.
The real four-file NWChem chain contains both of the first two shapes at once, which is why one uniform rule is wrong. Concatenating the Hessian’s displacement energies into a “trajectory” would be meaningless; taking only the last instance of the optimisation would throw its history away.
Grouping, and why position is not the rule
An instance continues the most recent task of the same program and kind whose last instance came from an earlier member and which the program disowned — reported failed or incomplete. Anything else stands alone.
- Position was the obvious rule and is wrong. A restart deck does not re-declare work already done: the four-file chain has
[optimisation, frequency]in member 1 and[frequency]in members 2, 3 and 4. Matching by position pairs member 2’s frequency against member 1’s optimisation, finds a kind mismatch, and reports five separate tasks where there are two. - Only a disowned task continues. That is what separates a restart from a re-run, and it is the only thing that does: the same deck submitted twice produces two identical task lists and nothing in either file says which happened. A task whose outcome is unknown is left alone rather than guessed at.
- The program has to match too, or an NWChem single point and an ORCA single point merge into one task, which is not a continuation of anything.
Order comes from the caller, never from mtime
Members are chained in the order the caller gave them, because the caller knows. Filesystem timestamps are not usable for this: copying results off a cluster with scp rewrites every mtime, and that copy is the workflow studies exist to serve. Runs record their own start time inside the file, which survives the copy, so that is used to check the caller’s order — a disagreement arrives in study.warnings rather than silently reordering anything.
One thing reaches back into parsing, and only one: a member with no geometry of its own is retried with its predecessor’s (inherit_structure_and_retry in study/build.rs), for the NWChem restart-without-echo case. The document records where its geometry came from, so an inherited structure is never indistinguishable from a stated one. Everything else about a study is post-parse.
study_from_documents assembles a study from documents already in hand, without touching the filesystem — the WASM viewer and the Python bridge both hold documents rather than paths.
Frontends
- Desktop:
MenuAction::OpenStudy(ui/shell/src/menu/file_menu.rs). The panel state isviewer/core/src/ui_state/study_state.rs, which deliberately holds aStudyMemberViewlist rather than aStudy— a study carries a fullCanonicalDocumentper member, which is the right thing on disk and the wrong thing to keep alive for the life of a window. - Python:
#[pyclass(name = "Study")]inextensions/python-bridge/src/python/study.rs. - CLI:
orbitron inspect a.out b.out c.outandorbitron canonical export a.out b.outboth take many sources; export writes them as one.orbpackwhose manifest is the study.
For the user-facing walkthrough see Studies in the User Guide.
7.3 Resource reports
capacity::ResourceReport keeps source parsing and decoded scene costs as separate stages. Frontends should preserve that split. A raw NWChem output can be expensive to parse but produce a small scene, while a compact bundle can decode into a large trajectory or grid.
The report uses these types:
| Type | Contract |
|---|---|
FileOpenEstimate |
Source bytes, format multiplier, optional count-derived decoded-memory minimum, projected incremental host memory, available-memory target, and separate format/hint confidence |
SceneResourceInputs |
Primary scene counts, a nested render request, total frames, resident records, packed positions, grid points, and requested features |
SceneRenderInputs |
Visible atom and bond instances, an optional viewport, and prepared mesh counts |
SceneRenderEstimate |
Exact instance, mesh, and readback buffers plus logical color and depth attachment bytes |
SceneResourceEstimate |
Approximate scene records, the render estimate, exact packed coordinate and grid payloads, and costs that remain unestimated |
ByteEstimate |
Tagged exact, projected, or range JSON so a frontend cannot mistake a heuristic for a measurement |
UnestimatedCost |
Requested work such as labels, bond inference, surface meshes, electronic structure, and undo history that lacks a defensible byte model |
ReliefOption |
A cheaper path the frontend may offer explicitly, such as disabling labels or retaining bounded trajectory storage |
frame_count means frames available. It does not mean frames resident. Use resident_atom_records and resident_bond_records for eager snapshots and frame caches, and packed_position_count for a packed f32 coordinate attachment. This distinction is required for indexed and packed trajectories: 10,000 available frames with a three-frame cache must not be estimated as 10,000 expanded SceneSnapshot values.
FileKind::estimate_with_hints keeps the calibrated source-size estimate as a floor. Header or bounded-scan counts can raise that projection through atom records, additional-frame positions for sources that advertise a trajectory, and raw volumetric values, but they never lower it. Passing None returns the same value as FileKind::estimate. A prefix_lower_bound hint therefore catches an already-large prefix without pretending it describes the unscanned tail. prefix_observed has a different meaning: an application printed that count in the prefix, but a later job stage may describe another system. Frontends must not present it as a lower bound for the complete file.
Bundle inspection also reports packed_trajectory_bytes separately from total uncompressed attachment bytes. The bundle-derived retained-scene floor adds atom records, resident trajectory positions, selected grid values, and the packed coordinate attachment. Bundle opening applies the bundle multiplier to the larger of stored bytes and that floor because manifest decoding and scene reconstruction create transient copies. Do not apply the same rule to every attachment: a bundled raw source is retained for provenance and import but is not decoded during an ordinary scene open.
Current writers serialize the same fixed-shape facts to resources.json before manifest.json. inspect_resources_path caps the actual uncompressed record at 64 KiB and returns without inflating the manifest or attachments. The measured million-atom record is 352 bytes beside a 114.8 MB manifest. Legacy bundles without the entry retain bounded manifest inspection.
Treat the compact record only as preflight evidence. container::open rederives it from the parsed canonical payload and refuses a mismatch before exposing the bundle. Code that sizes an allocation or consumes an attachment must use the validated full-open path, not the resource-only inspection result.
capacity::estimate_file_open(path, mode) is the shared local-file entry point. It combines file metadata, FileKind classification, and the bounded source inspector, then retains the size multiplier as the floor. The desktop, CLI and Python bridge gates all pass that returned FileOpenEstimate to Operation::EstimatedFileOpen. Do not rebuild a file-open operation from byte length alone in a frontend; doing so discards the counts that can expose a small file declaring a very large scene.
The render estimate gets instance and mesh sizes from Rust’s active layouts. Those source, staging, vertex, index, uniform, and readback buffer payloads are exact before allocator or device padding. Viewport color and depth attachments are marked layout_derived: Orbitron knows their logical pixel and sample counts, but Metal, Vulkan, DX12, and WebGPU may allocate more. The largest-buffer field covers buffers only, not textures.
Prepared meshes have exact counts. A requested surface that has not been extracted does not. It remains UnestimatedCost::SurfaceMeshes until the marching-cubes or cartoon result exists, which prevents a grid-size heuristic from masquerading as a known triangle count. Scene record storage uses the backbone’s conservative per-record figures and stays projected. Variable-size properties and other unmeasured work also stay in unestimated_costs; do not fold them into an apparently complete total.
capacity::bundle_preparation_command returns a shell-safe canonical export command only when bounded inspection recognizes a raw source. It returns None for unknown inputs and existing .orbpack files. Resource gates attach that command to ResourceReport::bundle_preparation_command only when the source reaches a warning band. The same report also adds prepare_bundle_near_source to relief_options. CLI and TUI warnings print the command directly. The desktop warning displays it before opening, and Scene Info retains it with a copy control after the scene loads.