Cohesive energies and the energy-engine abstraction¶
The cohesive (atomization) energy of a solid is the energy needed to pull it apart into free atoms. It is the quantity the solid-state basis-optimisation work optimises against, and computing one is not a single calculation but a sequence: relax the cell, take a single point, correct for zero-point motion, compute the free atoms, and assemble.
vibe_basis.pipeline runs that sequence. vibe_basis.engine is the
seam that decides which program runs each step.
Note
These live in the co-located vibe-basis driver, so they need vibe-qc’s
basisopt extra. See
The external-program path.
The plan they implement is vibe-basis/ROADMAP.md.
The engine abstraction¶
Every engine implements one protocol, EnergyEngine, so which program
computes a number is configuration rather than structure:
Role |
Engine |
Where it lives |
|---|---|---|
Primary, Gaussian |
|
|
Primary, plane wave |
vibe-qc GPW / GAPW |
not wired yet |
Fallback, Gaussian |
|
|
Fallback, plane wave |
|
|
vibe-qc is the engine. The external ones are fallbacks and cross-checks. They exist to unblock work whose vibe-qc capability has not landed, and to give an independent second opinion: agreement between two implementations is evidence in a way that one implementation agreeing with itself is not.
The primary engine lives outside the driver package while the fallbacks
live inside it. That inversion is deliberate. VibeQcEngine needs
vibe-qc’s compiled core; Crystal23Engine needs only subprocess and
file I/O. Keeping the external engines dependency-free is what makes
the standalone ./vibe-basis/scripts/install.sh route lightweight for a
collaborator who has their own CRYSTAL and no interest in building vibe-qc.
vibe-basis is not on PyPI yet, so the managed source installer is the supported
onboarding path.
Every number carries its provenance¶
An engine returns an EngineEnergy, not a bare float:
from vibe_basis.engines import Crystal23Engine
from vibe_basis.transports.local import LocalTransport
from vibe_basis.io.structures import STRUCTURES
engine = Crystal23Engine(transport=LocalTransport())
result = engine.crystal_energy("<inline basis text>", STRUCTURES["MgO"], method="pbe")
print(result.ok) # did it converge and complete?
print(result.energy) # Hartree per unit cell
print(result.engine) # "crystal23"
print(result.engine_version) # what the output banner actually said
print(result.failure_mode) # None on success
“MgO is -275.4776 Ha” is not a result. “CRYSTAL23 computed -275.4776 Ha for MgO at RHF/pob-TZVP” is. A campaign that swaps engines as easily as this one does has to record which one produced each point, or it cannot be audited afterwards.
If you only want a number, evaluate_crystal and evaluate_atom return
float | None. They are derived from the rich methods, not a parallel
implementation, so the two views cannot disagree.
VibeQcEngine defaults are wiring defaults, not converged settings¶
Three of them will bite a first campaign if taken at face value:
Gamma point only. The periodic single point runs at a single k-point unless you say otherwise. That is enough to check the wiring and far too coarse for a production number. Pass a converged mesh with
VibeQcEngine(kpoints=...).BIPOLE, not GDF. GDF needs an auxiliary fitting basis, and a freshly emitted candidate basis has none registered. Borrowing another basis’s JKFIT set would lay a fitting error on top of the basis-incompleteness signal the objective exists to measure, so the route that needs no auxiliary basis is the default. Override through
periodic_kwargsif you have a fitting set you trust for the basis in hand.Static lattice unless another engine relaxes it.
VibeQcEnginedoes not advertiserelax: BIPOLE variable-cell optimization is fail-closed pending a certified stress and coupled atom/cell optimizer. UseCohesivePipeline(..., relax=False)for a static-lattice calculation or select an engine with a certified cell optimizer.
Zero-point energy: VibeQcEngine has Gamma-point phonons¶
VibeQcEngine computes the ZPE stage itself, on the same BIPOLE route,
k-mesh and functional as the single point. Crystal23Engine still has no
phonon path and records a skipped stage.
Three things to know before quoting a number from it.
It is a Gamma-only ZPE, which is an approximation for a solid. It
samples one q-point and therefore misses the dispersion of the acoustic
branches away from Gamma entirely. It is the standard cheap estimate and
it is the right size; it is not a converged thermodynamic quantity.
EngineEnergy.detail["approximation"] says "gamma_point_only".
Phonon-dispersion ZPE (supercell or q-mesh) is not implemented.
The Hessian is built from total energies, not from forces. On the
GAPW route, vibeqc.periodic_gapw_phonon differences the analytic GAPW
force, which is production there. On the BIPOLE route the analytic
gradient is a research-preview surface (see vibeqc.bipole_optimize) and
the production force is itself a finite difference, so differencing it
again would stack two FD layers. Central differences of the energy are
one layer resting on a quantity correct by construction. The price is
that it costs 18 N^2 + 1 SCFs and grows quadratically with cell size:
# doc-audit: skip
from vibeqc.basis_optimization.phonons import estimate_scf_count
estimate_scf_count(2) # 73 -- a 2-atom primitive cell
estimate_scf_count(8) # 1153
Budget it before starting a campaign. detail["n_scf_estimated"] carries
the same number into the run record.
No acoustic sum rule is imposed by default. The three Gamma acoustic modes are zero in exact arithmetic, so their residual is a direct measure of the calculation’s noise floor; zeroing it silently would make that unmeasurable. They are identified by projection onto the mass-weighted uniform-translation subspace (not by “the three lowest frequencies”, which picks the wrong modes as soon as an optical mode goes imaginary), excluded from the ZPE sum, and reported:
# doc-audit: skip
zpe = engine.zero_point_energy(basis_text, structure, method="pbe")
zpe.detail["acoustic_residual_cm1"] # the noise floor, uncorrected
zpe.detail["n_imaginary_modes_excluded"] # geometry is not a minimum
zpe.detail["asr"] # 'none' unless you asked
Pass VibeQcEngine(phonon_kwargs={"asr": "rowsum", "step_bohr": 0.03})
to change either. The finite-difference step is a real choice: an energy
Hessian divides by h^2, so SCF noise enters as eps/h^2 while
anharmonicity enters as O(h^2). The default is 0.02 bohr, twice the
GAPW module’s, for exactly that reason. Scan it on a new system class
rather than assuming it transfers –
examples/basisset_dev/gamma_phonon_gate.py does the scan and the
cross-route comparison.
The free-atom reference: aspherical and spin-polarised¶
Half of a cohesive energy is the free atoms, and an atom reference is
only comparable to an external one computed under the same
convention. Crystal23Engine emits SYMMREMO, UHF (or SPIN in the
DFT block) and SPINLOCK for an open-shell atom, so the solution is
aspherical and spin-polarised; VibeQcEngine runs the spin-polarised
ground-state multiplicity. Both record which convention they used in
EngineEnergy.detail["atom_reference"].
This is worth checking rather than assuming, because the failure is silent: a spin-restricted atom SCF converges perfectly well and simply returns the wrong energy. In the plane-wave reference work, spin-restricting the bromine reference alone moved KBr’s atomization energy by 37 kJ/mol, which is far larger than the basis-set effects a campaign is trying to resolve.
Counterpoise: matching the published pob protocol¶
Every free-atom deck in the pob cohesive-energy reference set uses
CRYSTAL’s ATOMBSSE, meaning the atom is computed in the ghost basis
of its own crystal rather than bare. The published numbers are
therefore counterpoise corrected, and a cohesive energy assembled from
bare atoms is not the same quantity.
# doc-audit: skip
pipeline = CohesivePipeline(engine, method="r2scan", counterpoise=True)
result = pipeline.run(basis_text, structure)
assert result.counterpoise_applied
The difference between the two is the basis-set superposition error, which for an incomplete basis is exactly what a basis-optimisation campaign is trying to reduce. Leaving it out does not cancel when you compare against a corrected reference.
Two consequences:
It is off by default, and
CohesiveResult.counterpoise_appliedrecords which you got. An engine that cannot do it (currentlyVibeQcEngine) produces a recorded skipped stage rather than quietly falling back to bare atoms.Counterpoise atoms are not shared across systems. A bare oxygen is the same calculation for MgO and CaO, so the cache computes it once. A counterpoise oxygen is computed in its own host’s ghost basis, so the two are genuinely different runs and the cache keys them by host. That is a real cost: it removes the saving that makes a campaign affordable, so use it for reference comparison rather than for every point of an optimisation.
nstar and rmax control how far the ghost shell extends. The
reference set uses per-system converged values (LiF 30 / 10.0, MgO
10 / 5.0), so treat Crystal23Engine’s defaults as a starting point
to converge rather than a setting to trust.
Failures are returned, not raised¶
A failed evaluation comes back with ok=False and a failure_mode. It
does not raise. Basis optimisation walks into infeasible regions
routinely: an exponent collapses, the overlap goes singular, the queue
drops a job. A derivative-free optimiser handles that by scoring the
point inf and moving on, so an exception would abort a campaign that
is minutes per evaluation and days long.
One consequence worth internalising: energy_if_ok() returns None for
a failed evaluation even when .energy holds a number. A
non-converged SCF’s last-cycle energy is kept for diagnosis, and
silently consuming it as if it were converged is exactly the failure
that method exists to prevent.
The pipeline¶
from vibe_basis.cache import EnergyCache
from vibe_basis.pipeline import CohesivePipeline
from vibe_basis.engines import Crystal23Engine
from vibe_basis.transports.local import LocalTransport
from vibe_basis.io.structures import STRUCTURES
pipeline = CohesivePipeline(
Crystal23Engine(transport=LocalTransport()),
method="pbe", # bulk single point and the free atoms
relax_method="pbe", # geometry relaxation, if the engine can
relax=True,
zero_point=True,
cache=EnergyCache("runs/campaign.jsonl"),
)
result = pipeline.run("<inline basis text>", STRUCTURES["MgO"])
print(result.summary())
Stages, in order:
Relax the cell and internal coordinates at
relax_method, so the basis is judged at its own equilibrium geometry rather than at one some other basis produced.Single point at
method, on the relaxed geometry. The usual pattern is a cheap GGA relaxation followed by a hybrid single point.Zero-point energy of the solid.
Isolated atoms, one per distinct element.
Assemble.
With VibeQcEngine, request relax=False: its capability set deliberately
causes a requested relaxation stage to be recorded as skipped rather than
silently returning the input geometry as if it had been optimized.
The arithmetic, per formula unit:
E_coh = SUM_atoms E_atom - E_bulk_per_formula_unit - ZPE_per_fu
positive for a bound solid, matching the sign convention of
vibeqc.atomization.AtomizationResult. Zero-point motion
reduces the cohesive energy: the solid’s true ground state sits ZPE
above its static-lattice minimum, while free atoms have no vibrations to
correct.
Formula units are counted from the cell’s stoichiometry, as the greatest
common divisor of the per-element counts. Rocksalt MgO with 4 Mg and 4 O
is 4 formula units; fluorite CaF2 with 4 Ca and 8 F is also 4. Pass
n_formula_units= explicitly for a cell whose formula is not the reduced
one, such as a defect supercell.
Read zero_point_applied before comparing to anything¶
# doc-audit: skip
if not result.zero_point_applied:
... # this is a STATIC-LATTICE cohesive energy
A static-lattice cohesive energy and a ZPE-corrected one are different quantities, and for these solids they differ by tens of kJ/mol, which is the same size as the basis-set effects being measured. Comparing one to a reference computed as the other produces a discrepancy that looks like physics and is not.
So CohesiveResult states which it is, and the pipeline never quietly
substitutes a default. If an engine cannot relax, or cannot do phonons,
that stage is recorded as skipped, with the engine named in the
reason:
# doc-audit: skip
for stage in result.stages:
print(stage.name, stage.status, stage.reason)
# relax skipped engine 'crystal23' has no relaxer
# single_point ok None
# zero_point skipped engine 'crystal23' has no phonons
# atom_Z8 ok None
# atom_Z12 cached None
Returning the input geometry for a missing relaxation would look like a converged relaxation; returning zero for a missing ZPE would look like a solid with no vibrations. Both would produce a number that looks finished and is not.
A failed zero-point stage is survivable, and downgrades the result to
static-lattice with zero_point_applied=False. A failed relaxation is
not: an unconverged geometry is not a geometry, so the run stops
before spending an SCF on it.
Caching and resume¶
EnergyCache is content-addressed over an append-only JSONL journal.
Pass one cache across every system in a campaign:
# doc-audit: skip
cache = EnergyCache("runs/campaign.jsonl")
for name in ("MgO", "CaO", "BaO"):
CohesivePipeline(engine, method="pbe", cache=cache).run(basis_text, STRUCTURES[name])
Free-atom entries are keyed without a system identifier, so the oxygen computed for MgO is reused for CaO and BaO. That is the win that matters: atoms are the expensive part. In the plane-wave reference work a single free fluorine atom cost 100 to 240 seconds while the solid was quick, so recomputing oxygen once per compound would dominate a campaign.
Because the journal is append-only and is the cache, a campaign killed
mid-run resumes rather than re-spending the queue time. A journal
truncated by kill -9 costs the one torn entry, not the file.
Keys derive from declared configuration only, never from observed
output. In particular the engine’s reported version is deliberately not
part of the key: Crystal23Engine cannot report one until its first
evaluation, so keying on it would change the key halfway through a run
and silently split the cache. The observed version is recorded in the
value instead, for audit.
Which CRYSTAL produced this?¶
Crystal23Engine targets CRYSTAL23, and the parsed result records the
major version read off the output banner:
from vibe_basis.backends.crystal import parse_output_file
parsed = parse_output_file("mgo.out")
print(parsed.crystal_version) # 23, or None if no banner in the text
print(parsed.version_matches(23)) # True / False / None
version_matches is three-valued on purpose. None means the banner
was not in the text, which is normal for a truncated output or a tail
slice, and is not a failure: an output with no visible banner but a
converged total energy is still a usable energy. False means the
banner said something else, and the energy is still parsed, but
attributing it to CRYSTAL23 would be false.
Enforcement is a policy decision, so it is opt-in. Set
Crystal23Engine(..., strict_version=True) for a production campaign,
where a stray binary on one host quietly contributing a few points is
the real hazard. Leave it off for a parity study that legitimately wants
CRYSTAL14 output.
See also¶
Molecular basis-set optimisation, the in-process analytic-gradient path.
Basis sets, the bundled library.
vibe-basis/ROADMAP.md, the milestones this implements.vibe-basis/TUTORIAL.md, a worked walkthrough.examples/basisset_dev/cohesive_pipeline_demo.py, runnable with no external program and no native build.