|
| 1 | +// Counterpoise / BSSE — Tier 3 (Boys-Bernardi). |
| 2 | +// |
| 3 | +// Pass bars: |
| 4 | +// 1. Sign: BSSE correction is POSITIVE for non-bonded fragments |
| 5 | +// in any incomplete basis (dimer's borrowed basis artificially |
| 6 | +// stabilizes; CP removes the stabilization, making ΔE_CP > ΔE). |
| 7 | +// 2. Ghost-only sanity: a fragment computed with its real atoms + |
| 8 | +// no ghosts must match the same fragment computed normally |
| 9 | +// (counterpoise reduces to plain HF when there's nothing |
| 10 | +// ghosted). |
| 11 | +// 3. Self-consistency: the supermolecule energy returned by |
| 12 | +// counterpoise must equal the plain HF energy on the same atoms. |
| 13 | +// 4. Magnitude bounded: STO-3G BSSE on H₂...H₂ at 3 Å should be |
| 14 | +// sub-mHa (small basis on a non-interacting pair). |
| 15 | + |
| 16 | +import { describe, expect, test } from "vitest"; |
| 17 | +import { runCounterpoise } from "../../src/chemistry/counterpoise.js"; |
| 18 | +import { computeMolecularIntegrals } from "../../src/chemistry/cg-molecular.js"; |
| 19 | +import { moleculeToShellsNuclei, type Atom } from "../../src/chemistry/atoms.js"; |
| 20 | +import { runRHFSCF } from "../../src/chemistry/hf-scf.js"; |
| 21 | + |
| 22 | +describe("Counterpoise / BSSE", () => { |
| 23 | + test("H₂...H₂ STO-3G at 3 Å: BSSE positive, sub-mHa, all converged", () => { |
| 24 | + // Two H₂ molecules end-to-end, 3 Å between the closest H atoms. |
| 25 | + // STO-3G is a poor basis → BSSE is the *only* "binding" you see |
| 26 | + // for two H₂ molecules far apart. |
| 27 | + const atoms: Atom[] = [ |
| 28 | + { symbol: "H", pos: [0, 0, 0] }, |
| 29 | + { symbol: "H", pos: [0, 0, 0.7414] }, |
| 30 | + { symbol: "H", pos: [0, 0, 3.7414] }, // 3 Å gap |
| 31 | + { symbol: "H", pos: [0, 0, 4.4828] }, |
| 32 | + ]; |
| 33 | + const cp = runCounterpoise( |
| 34 | + atoms, |
| 35 | + [ |
| 36 | + { atomIndices: [0, 1] }, |
| 37 | + { atomIndices: [2, 3] }, |
| 38 | + ], |
| 39 | + "sto-3g", |
| 40 | + { useDIIS: true, maxIter: 200, energyTol: 1e-10, densityTol: 1e-8 }, |
| 41 | + ); |
| 42 | + |
| 43 | + expect(cp.allConverged).toBe(true); |
| 44 | + // BSSE = ΔE_CP − ΔE = Σ(E_bare − E_CP) ≥ 0 by the variational |
| 45 | + // principle (ghost-augmented monomer is always ≤ bare). |
| 46 | + expect(cp.bsseCorrection).toBeGreaterThan(0); |
| 47 | + // Magnitude sub-mHa on STO-3G H₂...H₂ at 3 Å. |
| 48 | + expect(cp.bsseCorrection).toBeLessThan(1e-3); |
| 49 | + // CP-corrected ΔE > uncorrected ΔE always (CP raises the |
| 50 | + // interaction energy by exactly bsseCorrection). |
| 51 | + expect(cp.interactionEnergyCP).toBeGreaterThan(cp.interactionEnergy); |
| 52 | + expect(cp.interactionEnergyCP - cp.interactionEnergy).toBeCloseTo(cp.bsseCorrection, 12); |
| 53 | + }); |
| 54 | + |
| 55 | + test("supermolecule energy returned by counterpoise matches plain HF", () => { |
| 56 | + const atoms: Atom[] = [ |
| 57 | + { symbol: "H", pos: [0, 0, 0] }, |
| 58 | + { symbol: "H", pos: [0, 0, 0.7414] }, |
| 59 | + { symbol: "H", pos: [0, 0, 3.0] }, |
| 60 | + { symbol: "H", pos: [0, 0, 3.7414] }, |
| 61 | + ]; |
| 62 | + // Plain HF reference. |
| 63 | + const { shells, nuclei, nElectrons } = moleculeToShellsNuclei(atoms, "sto-3g"); |
| 64 | + const integrals = computeMolecularIntegrals(shells, nuclei); |
| 65 | + const plain = runRHFSCF(integrals, nElectrons, { |
| 66 | + useDIIS: true, maxIter: 200, energyTol: 1e-10, densityTol: 1e-8, |
| 67 | + }); |
| 68 | + expect(plain.converged).toBe(true); |
| 69 | + |
| 70 | + const cp = runCounterpoise( |
| 71 | + atoms, |
| 72 | + [{ atomIndices: [0, 1] }, { atomIndices: [2, 3] }], |
| 73 | + "sto-3g", |
| 74 | + { useDIIS: true, maxIter: 200, energyTol: 1e-10, densityTol: 1e-8 }, |
| 75 | + ); |
| 76 | + |
| 77 | + expect(Math.abs(cp.supermoleculeEnergy - plain.energy)).toBeLessThan(1e-9); |
| 78 | + }); |
| 79 | + |
| 80 | + test("single-fragment counterpoise reduces to plain HF (no ghosts)", () => { |
| 81 | + // If you put every atom in ONE fragment, there are no ghosts — |
| 82 | + // the "fragment in dimer basis" run is identical to the bare |
| 83 | + // monomer run, and BSSE = 0. |
| 84 | + const atoms: Atom[] = [ |
| 85 | + { symbol: "H", pos: [0, 0, 0] }, |
| 86 | + { symbol: "H", pos: [0, 0, 0.7414] }, |
| 87 | + ]; |
| 88 | + const cp = runCounterpoise( |
| 89 | + atoms, |
| 90 | + [{ atomIndices: [0, 1] }], |
| 91 | + "sto-3g", |
| 92 | + { useDIIS: true, maxIter: 200, energyTol: 1e-10, densityTol: 1e-8 }, |
| 93 | + ); |
| 94 | + expect(cp.allConverged).toBe(true); |
| 95 | + // With only one fragment: ΔE = ΔE_CP, so BSSE = 0. |
| 96 | + expect(cp.bsseCorrection).toBeLessThan(1e-9); |
| 97 | + // Fragment-with-ghosts == fragment-bare when there are no other atoms. |
| 98 | + expect(Math.abs(cp.fragmentEnergiesCP[0]! - cp.fragmentEnergiesBare[0]!)).toBeLessThan(1e-9); |
| 99 | + }); |
| 100 | + |
| 101 | + test("ghost-augmented fragment energy is lower (variational basis-set expansion)", () => { |
| 102 | + // E(fragment in dimer basis) ≤ E(fragment bare) by the |
| 103 | + // variational principle — adding basis functions can only |
| 104 | + // lower (or equal) the SCF energy. |
| 105 | + const atoms: Atom[] = [ |
| 106 | + { symbol: "H", pos: [0, 0, 0] }, |
| 107 | + { symbol: "H", pos: [0, 0, 0.7414] }, |
| 108 | + { symbol: "H", pos: [0, 0, 3.0] }, |
| 109 | + { symbol: "H", pos: [0, 0, 3.7414] }, |
| 110 | + ]; |
| 111 | + const cp = runCounterpoise( |
| 112 | + atoms, |
| 113 | + [{ atomIndices: [0, 1] }, { atomIndices: [2, 3] }], |
| 114 | + "sto-3g", |
| 115 | + { useDIIS: true, maxIter: 200, energyTol: 1e-10, densityTol: 1e-8 }, |
| 116 | + ); |
| 117 | + expect(cp.allConverged).toBe(true); |
| 118 | + for (let k = 0; k < 2; k++) { |
| 119 | + // ghost-augmented ≤ bare (variational; tiny shift in STO-3G). |
| 120 | + expect(cp.fragmentEnergiesCP[k]!).toBeLessThanOrEqual(cp.fragmentEnergiesBare[k]! + 1e-12); |
| 121 | + } |
| 122 | + }); |
| 123 | + |
| 124 | + test("validates fragmentation: throws on overlap + on missing atoms", () => { |
| 125 | + const atoms: Atom[] = [ |
| 126 | + { symbol: "H", pos: [0, 0, 0] }, |
| 127 | + { symbol: "H", pos: [0, 0, 0.7414] }, |
| 128 | + ]; |
| 129 | + // Overlap. |
| 130 | + expect(() => runCounterpoise( |
| 131 | + atoms, |
| 132 | + [{ atomIndices: [0, 1] }, { atomIndices: [1] }], |
| 133 | + "sto-3g", |
| 134 | + )).toThrow(/multiple fragments/); |
| 135 | + // Missing. |
| 136 | + expect(() => runCounterpoise( |
| 137 | + atoms, |
| 138 | + [{ atomIndices: [0] }], |
| 139 | + "sto-3g", |
| 140 | + )).toThrow(/not assigned/); |
| 141 | + // Out of range. |
| 142 | + expect(() => runCounterpoise( |
| 143 | + atoms, |
| 144 | + [{ atomIndices: [0, 1, 5] }], |
| 145 | + "sto-3g", |
| 146 | + )).toThrow(/out of range/); |
| 147 | + }); |
| 148 | +}); |
0 commit comments