"""Composite 3c methods — keyword-shortcut registry.
The "3c" methods of the Grimme school bundle a tuned small basis, a
modern dispersion correction (D3-BJ or D4), a geometric counterpoise
correction (:mod:`vibeqc.gcp`), and (sometimes) a modified short-range
correction (3c-SRB) into a single user-facing keyword. The result is a
single recipe that, on the GMTKN55 / S22 / X23 benchmarks, lands close
to a much-more-expensive parent functional + triple-zeta calculation at
small-basis cost.
This module is the **registry**: a single static dict mapping each
composite name (``"hf-3c"``, ``"pbeh-3c"``, ``"b97-3c"``,
``"b3lyp-3c"``, ``"r2scan-3c"``, ``"wb97x-3c"``, ``"hse-3c"``) to a
:class:`CompositeRecipe` describing the four ingredients plus an
availability marker. The keyword-shortcut dispatcher lives in
:mod:`vibeqc.runner` (the ``method=`` argument of :func:`run_job`
accepts composite names that resolve here).
Per CLAUDE.md § 9 (no surprise dependencies), every composite carries
an :class:`Availability` flag the dispatcher checks before running:
* ``RUNNABLE`` — every ingredient (basis + functional + dispersion +
gCP + sr_mod) is fully wired today; the keyword shortcut works
end-to-end. The user can ``run_job(method="hf-3c", ...)`` and get a
total energy.
* ``NEEDS_BASIS`` — the parent functional + dispersion + gCP framework
are wired, but the target basis is not yet bundled (or is gated
behind the basissetdev BSE-fetcher per CLAUDE.md § 4). The user can
still run the composite by passing ``basis_path=`` to point at a
local file with the published basis-set definition.
* ``PENDING_F1`` — the recipe depends on range-separated-hybrid (RSH)
Coulomb machinery (F1 in the v0.8.0 functional sweep) that has not
yet landed in vibe-qc's K-build. The dispatcher raises
:class:`CompositeUnavailable` with the pointer.
* ``PENDING_F3`` — same shape for meta-GGA τ-density support (F3 in
the v0.8.0 functional sweep). Required for r²SCAN-3c.
* ``PENDING_GCP_DATA`` — the basis-fit (σ, α, β) constants are
registered in :mod:`vibeqc.gcp`, but the per-element e_mis / n_virt
/ zeta tables are not yet bundled. Calling the composite raises
:class:`vibeqc.gcp.GCPDataMissing` with the contribute-via-PR
pointer.
The availability matrix is **transparent**: ``vibeqc.list_composites()``
returns the catalogue with status flags so a user can see at a glance
which recipes work today and which are gated.
The recipe specifications are pinned to the originating publications:
* **HF-3c** — Sure & Grimme, *J. Comput. Chem.* **34**, 1672 (2013).
* **PBEh-3c** — Grimme, Brandenburg, Bannwarth, Hansen, *J. Chem.
Phys.* **143**, 054107 (2015).
* **B97-3c** — Brandenburg, Bannwarth, Hansen, Grimme, *J. Chem. Phys.*
**148**, 064104 (2018).
* **r²SCAN-3c** — Grimme, Hansen, Ehlert, Mewes, *J. Chem. Phys.*
**154**, 064103 (2021).
* **ωB97X-3c** — Müller, Hansen, Grimme, *J. Chem. Phys.* **158**,
014103 (2023).
* **B3LYP-3c** — community-extension recipe; B3LYP-VWN5 + D3(BJ) +
gCP on def2-mSVP. Not a published "official" 3c composite but
widely used in spectroscopy workflows for IR / vibrational
frequencies (B3LYP's strength).
* **HSE-3c** — community-extension recipe; HSE06 + D3(BJ) + gCP on
def2-mSVP, the solid-state analogue of PBEh-3c.
Adding a new composite is a single :class:`CompositeRecipe` entry plus
the matching :class:`vibeqc.gcp.GCPParams` registration if a new basis
is involved — the dispatcher needs no changes.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Optional
__all__ = [
"Availability",
"CompositeRecipe",
"CompositeUnavailable",
"ShortRangeCorrection",
"list_composites",
"resolve_composite",
]
class CompositeUnavailable(RuntimeError):
"""Raised by :func:`vibeqc.run_job` when the requested composite
keyword names a recipe gated on infrastructure (RSH, mGGA τ, an
unbundled basis) that hasn't landed yet. The message points at the
blocking roadmap item.
"""
class Availability(str, Enum):
"""Per-recipe availability flag.
See module docstring for the full list of statuses and their
meaning. Values are strings (not bare enum constants) so they
survive JSON serialisation cleanly when the recipe table is
dumped into a structured log.
"""
RUNNABLE = "runnable"
NEEDS_BASIS = "needs_basis"
PENDING_F1 = "pending_f1_rsh"
PENDING_F3 = "pending_f3_mgga"
PENDING_GCP_DATA = "pending_gcp_data"
[docs]
@dataclass(frozen=True)
class ShortRangeCorrection:
"""A modified short-range pairwise correction in the spirit of
HF-3c's 3c-SRB term.
Sure & Grimme (*J. Comput. Chem.* 34, 1672 (2013)) introduced the
SRB term as a per-atom-pair exponential damping that corrects the
small-basis geometry error around equilibrium bond lengths. The
functional form is similar to D3-BJ's geometry-only piece:
E_SRB = -s_SRB · Σ_a Σ_{b>a} Z_a^{0.5} Z_b^{0.5}
· exp(-γ R_ab^{1/3})
where ``s_SRB`` is the per-recipe scaling factor, ``γ`` is the
per-recipe range parameter, and (Z_a^0.5 · Z_b^0.5) is a
nuclear-charge weighting.
Attributes
----------
name : str
Human-readable label for SCF-log dumping (e.g.
``"HF-3c short-range Becke correction (SRB)"``).
s_srb : float
Pairwise scaling factor.
gamma : float
Distance attenuation parameter (inverse bohr).
citation : str
Originating publication.
"""
name: str
s_srb: float
gamma: float
citation: str
[docs]
def evaluate(self, atomic_numbers, positions) -> float:
"""Evaluate E_SRB for a given (atomic_numbers, positions in
bohr) pair. Standalone — does not require an SCF result. Used
by the composite dispatcher to add the SRB contribution after
the SCF + dispersion + gCP sum.
Returns the energy in Hartree. Sign convention: SRB is
*binding*, so the returned value is negative for any
bound geometry.
"""
import numpy as np
positions = np.asarray(positions, dtype=float)
energy = 0.0
n = len(atomic_numbers)
for a in range(n):
za = float(atomic_numbers[a])
for b in range(a + 1, n):
zb = float(atomic_numbers[b])
R = float(np.linalg.norm(positions[a] - positions[b]))
if R < 1e-10:
continue
energy -= (self.s_srb
* (za * zb) ** 0.5
* np.exp(-self.gamma * R ** (1.0 / 3.0)))
return energy
@dataclass(frozen=True)
class CompositeDampingD3BJ:
"""D3-BJ damping parameters specific to a composite 3c recipe.
The 3c composites use *re-fit* D3-BJ parameters that differ from
the published damping for their parent functional. For example:
* PBEh-3c uses (s6, s8, a1, a2) = (1.0, 0.0, 0.4860, 4.5000),
whereas the standard PBE0-D3BJ damping is
(1.0, 1.2177, 0.4145, 4.8593).
* B97-3c uses (1.0, 0.4416, 0.3719, 3.9748).
* HF-3c uses (1.0, 0.8777, 0.4171, 2.9149).
Carrying the damping in the recipe (instead of letting the
dispersion module's per-functional lookup decide) makes the
composite recipe self-contained and avoids the wrong-default
failure mode where a name lookup returns the published damping
for a *different* method.
"""
s6: float
s8: float
a1: float
a2: float
citation: str
@dataclass(frozen=True)
class CompositeRecipe:
"""Specification of a single composite 3c method.
Attributes
----------
name : str
Lowercase canonical keyword (``"hf-3c"``, ``"pbeh-3c"``, …).
functional : str | None
Parent XC functional name passed to :class:`Functional`
(libxc alias). ``None`` for pure HF recipes (HF-3c).
basis : str
Target basis-set name. Passed to :class:`BasisSet` lookup.
dispersion : str
Dispersion correction: ``"d3bj"``, ``"d4"``, or ``"none"``.
d3bj_damping : CompositeDampingD3BJ | None
Composite-specific D3-BJ damping parameters. Set when
``dispersion == "d3bj"`` so the recipe is self-contained;
the dispatcher uses these directly rather than asking
:func:`vibeqc.d3bj_params_for` to look the functional up.
``None`` for ``dispersion == "d4"`` (where dftd4 has its own
per-composite damping table keyed on the composite name).
gcp_basis : str | None
Basis-set name keyed into the gCP parameter registry
(:mod:`vibeqc.gcp`). Usually equals ``basis`` but for some
composites the gCP key is a canonical alias (e.g. ``vdzp``
for the ωB97X-3c basis).
sr_mod : ShortRangeCorrection | None
Optional short-range Becke-like correction. None for recipes
whose basis contraction is tuned to make SRB unnecessary
(PBEh-3c, r²SCAN-3c).
availability : Availability
Status flag the dispatcher checks before running.
citation : str
Originating publication.
notes : str
Free-text per-recipe annotation surfaced in
``vibeqc.list_composites()`` and in the SCF log when the
composite is invoked.
"""
name: str
functional: Optional[str]
basis: str
dispersion: str
gcp_basis: Optional[str]
sr_mod: Optional[ShortRangeCorrection]
availability: Availability
citation: str
notes: str = ""
d3bj_damping: Optional[CompositeDampingD3BJ] = None
# ---------------------------------------------------------------------------
# The seven composite recipes catalogued for v0.9.0. Availability
# reflects the actual state of ``main`` at v0.9.0-prep time:
# * HF-3c → NEEDS_BASIS + PENDING_GCP_DATA (MINIX basis not bundled,
# MINIX gCP per-element
# tables not bundled).
# * PBEh-3c → NEEDS_BASIS + PENDING_GCP_DATA (def2-mSVP not
# bundled; modified
# PBE0 alias is a
# pending xc.cpp entry).
# * B97-3c → NEEDS_BASIS + PENDING_GCP_DATA.
# * B3LYP-3c → NEEDS_BASIS + PENDING_GCP_DATA.
# * r²SCAN-3c → PENDING_F3 (mGGA τ-density support).
# * ωB97X-3c → PENDING_F1 (RSH erf/erfc K-build).
# * HSE-3c → PENDING_F1 (RSH).
# As each prerequisite lands, the availability flag flips. The
# dispatcher's behaviour is governed by the flag, not by code-path
# branches scattered across the runner.
# ---------------------------------------------------------------------------
_RECIPES: dict[str, CompositeRecipe] = {}
def _register(r: CompositeRecipe) -> None:
_RECIPES[r.name.lower()] = r
_SRB_HF3C = ShortRangeCorrection(
name="HF-3c short-range Becke correction (SRB)",
s_srb=0.03,
gamma=0.7,
citation="Sure & Grimme, J. Comput. Chem. 34, 1672 (2013)",
)
_SRB_B973C = ShortRangeCorrection(
name="B97-3c short-range Becke correction (SRB)",
s_srb=0.0353,
gamma=0.703,
citation="Brandenburg, Bannwarth, Hansen, Grimme, J. Chem. Phys. 148, 064104 (2018)",
)
_register(CompositeRecipe(
name="hf-3c",
functional=None, # pure HF
basis="minix",
dispersion="d3bj",
gcp_basis="minix",
sr_mod=_SRB_HF3C,
availability=Availability.RUNNABLE,
citation="Sure & Grimme, J. Comput. Chem. 34, 1672 (2013)",
notes=(
"Mean-field qualitative method for very large systems; "
"MINIX basis = MINIS + d-functions on Na-Ar + d/p polarisation. "
"Basis + functional alias landed in v0.9.0; gCP per-element "
"tables for MINIX are the remaining bundling gate "
"(PENDING_GCP_DATA)."
),
d3bj_damping=CompositeDampingD3BJ(
s6=1.0, s8=0.8777, a1=0.4171, a2=2.9149,
citation="Sure & Grimme, J. Comput. Chem. 34, 1672 (2013)",
),
))
_register(CompositeRecipe(
name="pbeh-3c",
functional="pbeh-3c", # modified-PBE0 alias landed in v0.9.0 (xc.cpp)
basis="def2-msvp",
dispersion="d3bj",
gcp_basis="def2-msvp",
sr_mod=None,
availability=Availability.RUNNABLE,
citation="Grimme, Brandenburg, Bannwarth, Hansen, J. Chem. Phys. 143, 054107 (2015)",
notes=(
"Modified PBE0 with re-tuned HF exchange (42% vs PBE0's 25%) "
"on def2-mSVP. Strong noncovalent geometries; widely used as "
"the cheap geometry-prep step before a higher-level single "
"point. Basis + alias + D3-BJ damping all landed in v0.9.0; "
"gated only on per-element gCP data tables (PENDING_GCP_DATA "
"for def2-mSVP)."
),
d3bj_damping=CompositeDampingD3BJ(
s6=1.0, s8=0.0, a1=0.4860, a2=4.5000,
citation="Grimme, Brandenburg, Bannwarth, Hansen, J. Chem. Phys. 143, 054107 (2015)",
),
))
_register(CompositeRecipe(
name="b97-3c",
functional="b97-3c", # libxc XC_GGA_XC_B97_3C (id 327, pure GGA)
basis="def2-mtzvp",
dispersion="d3bj",
gcp_basis="def2-mtzvp",
sr_mod=_SRB_B973C,
availability=Availability.RUNNABLE,
citation="Brandenburg, Bannwarth, Hansen, Grimme, J. Chem. Phys. 148, 064104 (2018)",
notes=(
"Workhorse GGA composite; especially good for transition metals. "
"Becke 1997 GGA reparameterised by Brandenburg + the standard "
"3c stack. Basis + functional alias + D3-BJ damping all landed "
"in v0.9.0; gated only on per-element gCP data tables "
"(PENDING_GCP_DATA for def2-mTZVP)."
),
d3bj_damping=CompositeDampingD3BJ(
s6=1.0, s8=0.4416, a1=0.3719, a2=3.9748,
citation="Brandenburg, Bannwarth, Hansen, Grimme, J. Chem. Phys. 148, 064104 (2018)",
),
))
_register(CompositeRecipe(
name="b3lyp-3c",
functional="b3lyp", # vibe-qc's b3lyp is VWN5 (ORCA convention)
basis="def2-msvp",
dispersion="d3bj",
gcp_basis="def2-msvp",
sr_mod=None,
availability=Availability.RUNNABLE,
citation="Community extension (Grimme group lineage); see Najibi-Goerigk 2018 for benchmarks",
notes=(
"B3LYP-VWN5 + D3(BJ) + gCP on def2-mSVP. Particularly accurate "
"for IR / vibrational frequencies (B3LYP's traditional "
"strength). Not in the original Grimme 3c paper series; "
"registered here for the spectroscopy workflows that ask for "
"B3LYP. Damping inherits the standard B3LYP-D3BJ parameters "
"(Grimme-Ehrlich-Goerigk 2011)."
),
d3bj_damping=CompositeDampingD3BJ(
s6=1.0, s8=1.9889, a1=0.3981, a2=4.4211,
citation="Grimme, Ehrlich, Goerigk, J. Comput. Chem. 32, 1456 (2011) — standard B3LYP-D3BJ damping",
),
))
_register(CompositeRecipe(
name="r2scan-3c",
functional="r2scan", # mGGA — F3 landed in v0.9.0
basis="def2-mtzvpp",
dispersion="d4",
gcp_basis="def2-mtzvpp", # gCP intentionally zero by design
sr_mod=None,
availability=Availability.RUNNABLE,
citation="Grimme, Hansen, Ehlert, Mewes, J. Chem. Phys. 154, 064103 (2021)",
notes=(
"'Swiss army knife' of the modern 3c stack — best general-"
"purpose composite per the published GMTKN55 / TM-track "
"benchmarks. F3 (meta-GGA τ-density support) landed in "
"v0.9.0; def2-mTZVPP contraction is tuned so the gCP term "
"is zero by construction. Still gated on def2-mTZVPP basis "
"bundling — supply a local .g94 to run end-to-end today."
),
))
_register(CompositeRecipe(
name="wb97x-3c",
functional="wb97x-v", # range-separated hybrid (F1 wired v0.9.0)
basis="vdzp",
dispersion="d4",
gcp_basis="vdzp",
sr_mod=None,
availability=Availability.RUNNABLE,
citation="Müller, Hansen, Grimme, J. Chem. Phys. 158, 014103 (2023)",
notes=(
"Range-separated hybrid 3c composite — vDZP basis carries "
"Stuttgart small-core ECPs. Drug-like / pharma workhorse, "
"barrier-height benchmarks. F1 K-build (erf-Coulomb K_LR) "
"landed in v0.9.0 — SCF + D4 dispersion work end-to-end. "
"gCP per-element data for vDZP is the remaining piece: the "
"TOML carries the published (σ, α, β) fit constants but the "
"Müller 2023 SI per-element tables are not yet bundled, so "
"the gCP step logs SKIPPED with E_gCP=0. This is the same "
"honest-marker behaviour as the v0.9.0-prep HF-3c / PBEh-3c "
"first cut — the recipe runs, the total is marked incomplete."
),
))
_register(CompositeRecipe(
name="hse-3c",
functional="hse06", # screened RSH (F1 wired v0.9.0)
basis="def2-msvp",
dispersion="d3bj",
gcp_basis="def2-msvp",
sr_mod=None,
availability=Availability.RUNNABLE,
citation="Community extension; HSE06 + D3(BJ) + gCP on def2-mSVP",
notes=(
"Solid-state range-separated variant — HSE06 (screened RSH "
"with α=0, β=0.25, ω=0.11) + def2-mSVP + D3(BJ) + gCP. "
"F1 K-build landed in v0.9.0; both molecular and (when the "
"periodic RSH path lands) cluster-embedded variants run via "
"the same recipe. Provides the molecular-cluster analogue of "
"HSE06's solid-state hybrid behaviour."
),
d3bj_damping=CompositeDampingD3BJ(
s6=1.0, s8=0.0, a1=0.4860, a2=4.5000,
citation="Inherits PBEh-3c damping (Grimme 2015); HSE-3c is a "
"community-extension composite without published D3-BJ "
"damping of its own.",
),
))
def list_composites() -> list[CompositeRecipe]:
"""Return all registered composite recipes, sorted by keyword name.
Useful for surface introspection — e.g. for documentation
generation, for CLI help, and for the v0.9.0 release-prep
availability matrix in :doc:`docs/user_guide/composites.md`.
"""
return [_RECIPES[k] for k in sorted(_RECIPES.keys())]
def resolve_composite(name: str) -> Optional[CompositeRecipe]:
"""Look up a composite recipe by keyword name. Case-insensitive,
accepts both ``"wb97x-3c"`` and ``"ωb97x-3c"`` (the unicode-omega
form gets ASCII-normalised). Returns ``None`` when the name is
not a registered composite — caller decides whether that's an
error.
"""
key = name.lower().strip()
# Accept unicode omega in the wb97 family.
key = key.replace("ω", "w").replace("Ω", "w")
# Accept r²scan-3c with the superscript.
key = key.replace("r²", "r2").replace("r²scan", "r2scan")
return _RECIPES.get(key)