An N-fragment, charge-aware counterpoise architecture#
- Status:
Draft – design agreed, implementation not started
- Companion:
orca_step’s Phase-1BSSEsub-step (two fragments, neutral singlet only), scoped inorca_step/docs/developer_guide/campaigns/2026-07-09/bsse_scope.rst- Supersedes (in part):
~/Work/SEAMM/BSSE_general_step_design.rst(2026-07-09) – the physics and validation plan there still hold; the architecture section is superseded by this document. See “Relationship to earlier documents” below.- Motivating plan:
~/Sites/mlff-training/2026-07-28_ion-bsse-plan/– the Na+/Cl-/H2O pilot that requires per-fragment charge and N > 2 fragments, which is what forces this generalization.
Goal#
Generalize the existing two-fragment, neutral-singlet ORCA counterpoise (BSSE) correction to an arbitrary number of fragments with independent per-fragment charge, without building one monolithic, code-reaching-into-everything driver step (the QCEngine/QCManyBody failure mode, evaluated and rejected in the ion-BSSE plan). Scope for this phase: ORCA only. Psi4 is the planned second engine and a cross-validation oracle, but is out of scope for the work described here.
The physics#
Full-cluster-basis site-site counterpoise, the natural generalization of Boys-Bernardi to N fragments:
with the analogous gradient. This needs 2N + 1 calculations per geometry:
the full cluster (all atoms real), N calculations of “fragment i in the full
cluster basis” (fragment i real, every other fragment ghosted), and N
calculations of “fragment i alone” (only fragment i’s atoms, its own
basis). At N = 2 this is exactly the existing 5-calculation Boys-Bernardi
scheme BSSEGradient.cmp already runs, so N = 2 is the regression target
(see “Validation”, below).
(The motivating lab-notebook page describes this as “N + 2 job specs” in passing; the correct count, and what this document and the library implement, is 2N + 1. They coincide only at N = 2.)
Confirmed design detail: ORCA preserves full-cluster indexing#
The ion-BSSE plan flagged one thing to confirm before finalizing the
library’s job-spec shape: do the engines’ ghost mechanisms keep the full atom
list/order and just flag atoms real/ghost, or do they remove and renumber?
If the former, combine() never needs index remapping – only padding.
For ORCA this is already confirmed, by the existing Phase-1 implementation
(orca_step/orca_step/bsse.py::_ghost_xyz, lines 149-159): it writes every
atom of the complex for every job that uses ghosts, flagging ghost centres
with a trailing : on the element symbol – no atom is ever removed or
renumbered. Consequently:
The cluster job and the N fragment-in-cluster-basis jobs are already full-cluster-length, in cluster order. Their energies/gradients drop straight into the sum/difference with zero remapping.
Only the N fragment-alone jobs are a genuine subset (by construction – that is what “alone, own basis” means): a job of
3|F_i|gradient components that must be padded into the full-cluster-length vector at fragment i’s global atom indices, zero elsewhere.
So combine() is exactly “padding + sum/subtract,” as hoped, for ORCA.
Psi4’s side of this question turned out moot rather than merely deferred:
Psi4’s native bsse_type='cp' driver does its own internal ghosting and
combination and hands back an already-full-cluster-indexed result (confirmed
2026-08-04, see “Psi4 sub-step” below), so the Psi4 sub-step never calls
seamm_bsse.combine() at all.
Architecture#
Split into a small engine-agnostic library and a thin, generalized ORCA sub-step, per the ion-BSSE plan’s decision 3.
seamm_bsse (new package)#
Pure bookkeeping: no QM, no ORCA/Psi4/molsystem imports, unit-testable with
synthetic numbers alone. Structured like seamm_thermochemistry (plain
pyproject.toml + versioningit, no conda dependency, no entry points,
no Tk) rather than the plug-in cookiecutter template, since it registers
nothing with the flowchart.
fragment.py–Fragment(label, atom_indices, charge, multiplicity=1). A cluster is just a list ofFragment.job_specs.py–generate_job_specs(fragments) -> list[JobSpec], producing the 2N + 1 specs described above. EachJobSpeccarries: a label/kind ("cluster"/"fragment-in-cluster"/"fragment-alone"), the global atom indices present in that job, which of those are ghost, and the job’s charge/multiplicity.combine.py–combine(specs, results, n_atoms) -> CPResultwithenergy,gradient(full-cluster length),bsse_correction,interaction_energy,uncorrected_interaction_energy. Generalizes the N = 2 algebra currently hardcoded inorca_step/orca_step/bsse.py(lines 294-295 for the energy, 307-308 for the interaction energies) to arbitrary N, doing exactly the padding described above.validate.py(small) – fragment-charge/multiplicity sanity checks (sum of fragment charges equals the cluster’s total charge; see “Out of scope” for what is and is not validated in this phase).
orca_step changes#
Extract a low-level
run_orca_job(keyword_line, xyz_text, charge, multiplicity, extra_blocks=None, extra_files=None, make_wfx=False)out of the existingrun_orca(orca_base.py, line 176).run_orcatoday builds its geometry block fromself.get_system_configuration()and readsconfiguration.charge/configuration.spin_multiplicitydirectly; a BSSE sub-job is a different atom subset at a different charge on every call, so the new primitive takes geometry text and charge/multiplicity as arguments instead. Reuses the resource/MPI-environment/command-construction internalsrun_orcaandrun_orca_compoundalready share.Generalize
bsse.py: fragment definition becomes a list of N fragments (auto-detected molecules, or specified atom sets), each with an independent charge (multiplicity fixed at 1 in this phase – see “Out of scope”). GUI: a fragment table (default one row per auto-detected molecule, charge 0), following the existing “prevent invalid combinations in the GUI” rule for SEAMM steps._ghost_xyzgeneralizes from “always the full atom list, one ghost fragment” to “the atoms named in aJobSpec, with its ghost subset flagged” – the same trailing-:mechanism, just parameterized.run()callsseamm_bsse.generate_job_specs, runs each spec throughrun_orca_jobin its own sub-directory, and callsseamm_bsse.combineon the collected results.
molsystem – explicitly unchanged#
The earlier general-step design (BSSE_general_step_design.rst) treated a
first-class is_ghost column on molsystem atoms as a “critical
dependency” – but that was written for a different architecture (one driver
node abstracting engines behind a Model-Chemistry/MDI evaluate()
interface). Phase 1 never needed it: it hand-writes ghost-flagged xyz text
directly in the engine step, sidestepping molsystem entirely. This
architecture keeps doing that. No molsystem schema change is part of this
work; revisit only if/when a future engine’s native ghost mechanism genuinely
needs molsystem-level representation.
The Compound-script path: kept as the N = 2 oracle, then retired#
bssegradient.cmp / bssenergy.cmp (the ORCA Compound scripts Phase 1
drives) are not touched by this work, and are not deleted yet. They:
stay in
orca_step/orca_step/data/and continue to be invocable throughrun_orca_compound;serve as the regression oracle for milestone M2 below – the new
seamm_bsse-driven path must reproduce their energies and gradients (to the precision the water/FEC/EC campaigns were decided at) on existing neutral-singlet dimers before anything N > 2 or charged is trusted;are removed from
bsse.py’s production run path only after that regression gate passes – this is a deliberate two-step “confirm the new path is at least as good, then retire the old one” sequence, not a unilateral replacement, precisely because the Compound path is already-validated, previously-debugged machinery (it has one already-fixed energy-formula bug and one already-fixed COSX ghost-gradient blowup guard behind it – see the workspace memorybsse-corrected-gradients.md).
A known trade-off of retiring it: the Compound script runs all five (or
2N + 1) sub-calculations as one ORCA process, which can reuse SCF guesses
across the closely-related sub-jobs; seamm_bsse’s multi-job orchestration
runs each as an independent ORCA invocation. This is expected to cost some
wall-time at N = 2 in exchange for N-fragment and per-fragment-charge
generality; it is not expected to be measured or decided until the M2
regression numbers (energy/gradient parity and wall-time) are in hand.
Out of scope for this phase#
Psi4. Not implemented yet. Its architecture is decided (see “Psi4 sub-step” below) and differs from ORCA’s: a thin wrapper around Psi4’s native
bsse_type='cp'driver, not a second consumer ofgenerate_job_specs()/combine().Open-shell / multiplicity > 1 fragments. The ion pilot (Na+, Cl-, H2O) is closed-shell throughout;
Fragment.multiplicityexists in the data model for forward-compatibility but this phase only exercises (and only validates) multiplicity = 1 on every fragment.Hessians. Not part of the ion-BSSE plan’s roadmap for this phase; a CP-corrected Hessian (finite-difference over 2N + 1 already-expensive gradient sets, or 2N + 1 analytic Hessians) is a materially larger cost and design question, left for later.
Periodic / non-molecular-cluster systems. Per-atom CP is ill-defined for a periodic solid; this architecture is molecular-cluster only, as the earlier design doc also recommended.
VMFC / many-body-decomposed counterpoise. Full-cluster-basis CP (this document) gives one corrected total energy/gradient, which is what MLFF training needs; the combinatorial many-body variant is not part of this work.
Sequence / milestones#
Mirrors the ion-BSSE plan’s roadmap (2026-07-28_ion-bsse-plan), steps 1-3,
ORCA only:
Milestone |
Deliverable |
|---|---|
M0 |
This document; |
M1 |
|
M2 |
|
M3 |
Per-fragment charge. Na+···H2O,
Cl-···H2O, Na+···Cl-
validated against literature binding energies (the ion-BSSE
plan’s two-body work). PASSED (2026-08-04), real ORCA,
B3LYP-D3BJ/def2-TZVP CP interaction energies vs. approximate
literature references: Na+···Cl- well
minimum ≈ -136 kcal/mol at R ≈ 2.4-2.6 Å (lit. ≈ -133 kcal/mol
at Re ≈ 2.36 Å, Born-Haber from atomic D0/IP/EA),
decaying smoothly to -64 kcal/mol by 7 Å; Na+···H2O = -26.1 kcal/mol (lit. ≈ -24); Cl-···H2O = -15.6 kcal/mol (lit. ≈ -13). All within a few
kcal/mol on unoptimized, literature-informed geometries (no
relaxation at this level of theory) – exactly the residual
expected from geometry, not a wiring defect. Confirms
per-fragment charge ( |
N = 3 on the ORCA path (real ORCA, not just the synthetic N=3 unit tests in
seamm_bsse itself) PASSED (2026-08-04): a hand-built Na+···Cl-···H2O trimer (contact ion pair, R = 2.44 Å, plus
a water coordinating Na+ from a different direction than Cl-), same B3LYP-D3BJ/def2-TZVP level as the M3 two-body checks. Ran the
full 2N + 1 = 7 jobs correctly (confirmed from the printed per-fragment
charges: Na+/Cl-/H2O = +1/−1/0, and
“Running 7 ORCA job(s)”). CP-corrected interaction energy −150.2
kcal/mol: deeper than the isolated Na+···Cl- pair alone
(−136, from M3) but less than the naive pairwise sum of the
independently-validated two-body terms (−136 + −26 ≈ −162)
– the expected cooperative-saturation non-additivity (Na+, already
partially satisfied by Cl-, has less capacity left to bind water),
not a bug. Script:
orca_step/docs/developer_guide/campaigns/2026-07-09/validate_bsse_n3.py.
Psi4 sub-step: IMPLEMENTED (2026-08-04)#
The cross-engine check N = 3 was originally paired with – the Psi4
sub-step – is done as a first pass: psi4_step’s own BSSE sub-step
(molssi-seamm/psi4_step PR #41, draft, not yet
merged). Its architecture differs from the ORCA one in an important way.
Psi4 has a native N-fragment CP driver; ORCA does not. That native driver
is the whole reason seamm_bsse exists for ORCA (SEAMM has to do the
2N + 1 job-spec/combine() bookkeeping by hand because ORCA has nothing
built in). Psi4 does not have that gap: energy(...)/gradient(...)
with bsse_type='cp' on a ---separated, per-fragment-charge molecule
block runs the full N-fragment correction internally and returns the
already-combined, full-cluster-indexed result.
Confirmed empirically (2026-08-04, real Psi4 1.10, local seamm-psi4 conda
env – not the psi4.ini Docker default, which looks misconfigured,
pointing at a seamm-mopac container; unrelated, not touched):
gradient('scf', bsse_type='cp', return_wfn=True)on the water dimer returned a CP-corrected energy and a gradient with exactly 6 rows (the full 6-atom cluster) in one call – no remapping needed on the SEAMM side, because there is no combine step on the SEAMM side.A charged two-fragment case (Na+/Cl-, per-fragment
1 1/-1 1charge/multiplicity headers in the molecule block) gave a CP interaction energy of -136.0 kcal/mol – matching the ORCA hand-rolled M3 result (-136 kcal/mol) closely, at a different level of theory. Free independent cross-validation, before any Psi4 sub-step code exists.
Decision (confirmed with the user 2026-08-04): the Psi4 sub-step is a thin
wrapper around ``bsse_type=’cp’``, not a second consumer of
``seamm_bsse.generate_job_specs()``/``combine()``. This is also the
better validation architecture per decision 6 above: two genuinely
independent implementations (ORCA hand-rolled vs. Psi4-native) checking each
other, rather than the same combine() arithmetic run twice with a
different QM backend underneath. seamm_bsse’s role for Psi4 shrinks to
Fragment/validate_fragments (a shared fragment-definition/charge-
validation layer, for GUI consistency with the ORCA sub-step) – the
job-spec-generation and combine() pieces go unused for this engine.
One real implementation wrinkle, resolved during implementation:
psi4_step’s existing Energy sub-step builds geometry from the
global current configuration (system_db.system.configuration in
Psi4._convert_structure), not a per-node get_system_configuration()
like ORCA’s sub-steps. BSSE builds its own fragment-aware,
---separated, per-fragment charge/multiplicity molecule block instead of
reusing _convert_structure. A second wrinkle, also resolved: unlike ORCA,
psi4_step sub-steps don’t drive their own execution – the main Psi4
node concatenates every sub-step’s get_input() text into one shared
script and runs it as a single process, so BSSE.get_input() writes its
JSON result to an absolute path (its own sub-step directory), since the
shared process’s cwd is the main node’s directory, not each sub-step’s own.
Validated end-to-end with real Psi4 1.10 on the water dimer (the same
geometry the ORCA M2 regression used): the whole pipeline works, and the
HF/def2-SVP result cross-checks against the independently-validated ORCA
HF/def2-SVP result to ~2e-4 Eh – the expected, healthy level of
agreement between two different QC codes at the same nominal level of
theory (not the ~1e-9 machine-precision level the ORCA-vs-its-own-Compound-
script M2 regression showed, which compares the same code against itself).
Script: psi4_step/docs/developer_guide/campaigns/2026-08-04/
validate_psi4_bsse.py.
Charged fragments and N = 3 through this sub-step itself: PASSED
(2026-08-04). Same geometries and level of theory (B3LYP-D3BJ/def2-TZVP)
as the ORCA M3/N3 legs, run through BSSE.get_input()/analyze() (not
hand-written psi4 scripts this time):
Na+···Cl- = -136.352 kcal/mol (ORCA: -136.289)
Na+···H2O = -26.176 kcal/mol (ORCA: -26.110)
Cl-···H2O = -15.699 kcal/mol (ORCA: -15.638)
Na+···Cl-···H2O (N = 3) = -150.332 kcal/mol (ORCA: -150.204)
Agreement is ~0.05-0.13 kcal/mol throughout – the same healthy code-vs-code
level the neutral water-dimer check showed. Script:
psi4_step/docs/developer_guide/campaigns/2026-08-04/
validate_psi4_bsse_m3.py. (One bug caught and fixed in the validation
script itself, not the sub-step: an early run divided the raw Hartree value
in bsse.json by 4.184 – the kJ->kcal factor – instead of converting
Hartree->kcal directly, giving near-zero “interaction energies” even though
bsse.json and Psi4’s own printed table already agreed.)
Still not done: energy-of-formation support; advanced SCF convergence controls.
Validation plan#
Synthetic index-mapping tests (
seamm_bsse, no QM): hand-built fragment/atom-index cases with made-up per-job energies/gradients, checked against hand-computedE_CP/\nabla E_CPfor N = 2 and N = 3.Reproduce Phase 1 exactly (M2’s acceptance gate): same water/FEC/EC dimers, same method/basis, through both the Compound-script path and the new
seamm_bsse-driven path; energies and gradients must agree to the precision those campaigns were decided at.Finite-difference gradient check for a 3-fragment case once M3 lands, since no existing trusted reference covers N = 3.
Literature binding energies for the charged two-body cases (M3).
Relationship to earlier documents#
orca_step/docs/developer_guide/campaigns/2026-07-09/bsse_scope.rst– Phase 1. Unchanged by this work except for its eventual role change (production path -> regression oracle -> retired frombsse.py, per “The Compound-script path” above). Its scope-boundary list (two fragments, one charge/mult for all sub-calculations, analytic-gradient methods only) is exactly what this document lifts.~/Work/SEAMM/BSSE_general_step_design.rst(2026-07-09) – the original “general, code-agnostic BSSE step” design. Its physics (the 2N + 1 formula), results/metadata naming, and validation-plan items are still correct and reused above. Superseded: its architecture section (one monolithic driver node, engines abstracted behind a Model-Chemistry/MDIevaluate()interface,molsystemghost-atom column as a hard prerequisite) – replaced by the library-plus-thin-per-engine-substep split here, per the ion-BSSE plan’s decision 3 (rejecting the QCEngine-shaped monolith).~/Sites/mlff-training/2026-07-28_ion-bsse-plan/– the motivating plan (the “why”; not duplicated here). This document is the implementation-side companion for its “BSSE architecture” section and roadmap steps 1-3.
Open questions#
Wall-time cost of N x independent ORCA jobs vs. one Compound process, once M2’s numbers are in hand – may (or may not) motivate keeping a Compound-script fast path for the common N = 2, neutral-singlet case alongside the general one, rather than fully retiring it. Deliberately left open until the M2 data exists, rather than decided in advance.
Whether
seamm_bsse’s job-level parallelism (the 2N + 1 jobs of one geometry are independent) should be exploited now or deferred to a later performance pass; not needed for correctness.