symvb 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
symvb/system.py ADDED
@@ -0,0 +1,354 @@
1
+ """High-level convenience layer over :class:`~symvb.molecule.Molecule`.
2
+
3
+ This is a thin, *additive* facade. The low-level symbolic core is unchanged
4
+ and every object it returns (the SymPy ``H``/``S`` matrices, the ground-state
5
+ expression, the structure vectors) stays directly inspectable. The facade
6
+ just removes the boilerplate and the two recurring footguns:
7
+
8
+ * building the Hamiltonian no longer means three calls plus a hand-combine,
9
+ and you never scale the two-electron matrix by ``U`` yourself (it already
10
+ carries the integral name -- doing so silently squares it);
11
+ * expressing a VB structure as a basis vector no longer means re-deriving the
12
+ fermion sign of the canonical reordering by hand.
13
+
14
+ Typical use::
15
+
16
+ from symvb import System
17
+ sys = System.ring(6) # benzene pi ring, Hubbard U
18
+ H, S = sys.hamiltonian() # 400x400 SymPy matrices
19
+ E, c = System.from_structures(mol, [cov, ion]).ground_state()
20
+ w = sys.weights(groups=...) # Chirgwin-Coulson weights
21
+
22
+ Or compose the standalone helpers with a Molecule you built yourself::
23
+
24
+ from symvb.system import hamiltonian, ground_state, chirgwin_coulson
25
+ H, S = hamiltonian(mol, basis)
26
+ E, c = ground_state(H, S)
27
+ w = chirgwin_coulson(c, S)
28
+ """
29
+ import sympy as sp
30
+
31
+ from .molecule import Molecule
32
+ from .fixed_psi import FixedPsi, generate_dets
33
+ from .functions import standardize_det
34
+ from .slaterdet import SlaterDet
35
+
36
+ __all__ = [
37
+ 'System', 'hamiltonian', 'ground_state', 'chirgwin_coulson',
38
+ 'structure_vector',
39
+ ]
40
+
41
+ # reference point for picking the ground root of a symbolic GHEP
42
+ _DEFAULT_REF = {'h': -1, 's': 0, 'U': 1, 'J': 0, 'K': 0, 'M': 0,
43
+ 'h_s': -1, 'h_l': sp.Rational(-3, 10)}
44
+
45
+
46
+ def _standardize_full(det_string):
47
+ """Map ``det_string`` to its ``generate_det_strings``-format twin + sign.
48
+
49
+ Returns ``(canonical, sign)`` with ``|det_string> = sign * |canonical>``,
50
+ where ``canonical`` is the unique basis-format string for the same set of
51
+ spin-orbitals: alphabetically sorted alpha and beta labels interleaved
52
+ ``uLuL...`` with the majority-spin extras appended, exactly as
53
+ :func:`symvb.functions.generate_det_strings` emits it.
54
+
55
+ :func:`symvb.functions.standardize_det` alone is not that map: it fixes
56
+ the spin *pattern* but its pairwise flips can jump a creation operator
57
+ over same-spin neighbours, permuting the alphabetical order within the
58
+ alpha/beta blocks (e.g. ``'abcAB' -> 'aAcBb'``, not ``'aAbBc'``), so its
59
+ output is a valid det string of the same determinant that need not be in
60
+ the generated basis. Composing it with the within-block label sort of
61
+ :meth:`symvb.slaterdet.SlaterDet.get_sorted` (spin pattern unchanged),
62
+ with both fermion signs folded in, gives the true canonical map. A no-op
63
+ (sign ``+1``) on a string that is already in basis format.
64
+ """
65
+ std, flips = standardize_det(det_string)
66
+ fp = SlaterDet(std).get_sorted()
67
+ return fp.dets[0].det_string, ((-1) ** flips) * fp.coefs[0]
68
+
69
+
70
+ def _det_string(b):
71
+ """The canonical determinant string of a basis entry (FixedPsi/SlaterDet/str).
72
+
73
+ Returned in symvb's standard (interleaved, block-sorted) creation order so
74
+ that the determinant index this builds agrees with the standardized basis
75
+ that matrix construction and :func:`structure_vector` use. A no-op on an
76
+ entry that is already canonical.
77
+ """
78
+ if isinstance(b, str):
79
+ raw = b
80
+ elif hasattr(b, 'dets'): # FixedPsi
81
+ raw = b.dets[0].det_string
82
+ else: # SlaterDet
83
+ raw = b.det_string
84
+ return _standardize_full(raw)[0]
85
+
86
+
87
+ # --------------------------------------------------------------------------
88
+ # standalone helpers (usable without a System)
89
+ # --------------------------------------------------------------------------
90
+ def hamiltonian(molecule, basis, two_electron=True):
91
+ """Return ``(H, S)`` over ``basis``, with the two-electron block folded in.
92
+
93
+ ``basis`` is a list of ``FixedPsi`` / ``SlaterDet`` / determinant strings.
94
+ The two-electron block is **always** folded into ``H`` -- under the names
95
+ declared in ``subst_2e`` when the molecule has them, otherwise under the
96
+ default ``T_<abcd>`` integral names. The integrals are already inside
97
+ ``H``; do **not** multiply the two-electron matrix by ``U`` yourself.
98
+
99
+ Pass ``two_electron=False`` for an intentionally one-electron model
100
+ (a Hueckel-level ``H``): this returns the bare ``build_matrix(op='H')``
101
+ and skips the two-electron build, which dominates the cost on large
102
+ bases.
103
+ """
104
+ H = molecule.build_matrix(basis, op='H')
105
+ S = molecule.build_matrix(basis, op='S')
106
+ if two_electron:
107
+ H = H + molecule.o2_matrix(basis)
108
+ return H, S
109
+
110
+
111
+ def structure_vector(structure, basis_dets):
112
+ """Expand a VB structure as a column vector over ``basis_dets``.
113
+
114
+ ``structure`` is a ``FixedPsi`` (e.g. a Rumer/Heitler-London structure built
115
+ with ``coupled_pairs``); ``basis_dets`` is the list of determinant strings
116
+ of the target basis (any creation order). Both sides are brought to the
117
+ same canonical form: each basis string and each structure determinant is
118
+ mapped through the full standardization (spin pattern via
119
+ :func:`symvb.functions.standardize_det`, then within-block label sort),
120
+ with both fermion signs folded into the coefficient. Alpha-alpha-beta-beta
121
+ and other non-canonical spin patterns, including the unequal-filling cases
122
+ where ``standardize_det`` alone leaves the generated basis (e.g. 3 alpha /
123
+ 2 beta hole determinants), are therefore placed correctly. Use this to
124
+ project a structure onto an explicit determinant basis (e.g. an FCI ground
125
+ state for weights); to build ``(H, S)`` over the structures themselves,
126
+ ``hamiltonian`` / ``build_matrix`` now canonicalize internally.
127
+ """
128
+ fp = FixedPsi(structure)
129
+ fp.canonicalize()
130
+ idx = {}
131
+ for i, b in enumerate(basis_dets):
132
+ key, bsign = _standardize_full(b)
133
+ if key in idx:
134
+ raise ValueError(
135
+ "basis determinants %r and %r are the same determinant "
136
+ "(canonical form %r)" % (basis_dets[idx[key][0]], b, key))
137
+ idx[key] = (i, bsign)
138
+ v = sp.zeros(len(basis_dets), 1)
139
+ for d, c in fp:
140
+ key, dsign = _standardize_full(d.det_string)
141
+ if key not in idx:
142
+ raise ValueError(
143
+ "structure determinant %r (canonical form %r) is not in the "
144
+ "target basis" % (d.det_string, key))
145
+ i, bsign = idx[key]
146
+ v[i] += dsign * bsign * c
147
+ return v
148
+
149
+
150
+ def chirgwin_coulson(c, S, groups=None, simplify=False):
151
+ """Chirgwin-Coulson weights of coefficient vector ``c`` under metric ``S``.
152
+
153
+ ``w_i = c_i (S c)_i / (c^T S c)`` (the metric makes them sum to one even for
154
+ non-orthonormal ``c``). If ``groups`` (a list of index lists) is given, the
155
+ summed weight per group is returned instead. Accepts either SymPy
156
+ matrices/vectors or NumPy arrays and returns the matching type. ``simplify``
157
+ is off by default: simplifying raw symbolic weights can be very slow, and
158
+ it is usually cheaper to substitute numeric values first; pass
159
+ ``simplify=True`` only for small closed forms.
160
+ """
161
+ try:
162
+ import numpy as np
163
+ if isinstance(c, np.ndarray): # numeric path keyed on the coefficient vector
164
+ c = np.asarray(c, float).ravel()
165
+ S = np.asarray(S, float)
166
+ Sc = S @ c
167
+ w = c * Sc / (c @ Sc)
168
+ if groups is None:
169
+ return w
170
+ return np.array([w[list(g)].sum() for g in groups])
171
+ except ImportError:
172
+ pass
173
+ c = sp.Matrix(c)
174
+ S = sp.Matrix(S) # accept a numpy metric alongside a symbolic c
175
+ Sc = S * c
176
+ norm = (c.T * Sc)[0]
177
+ w = [c[i] * Sc[i] / norm for i in range(c.rows)]
178
+ if groups is not None:
179
+ w = [sum(w[i] for i in g) for g in groups]
180
+ if simplify:
181
+ w = [sp.simplify(x) for x in w]
182
+ return sp.Matrix(w)
183
+
184
+
185
+ def _ref_subs(H, S, ref):
186
+ syms = H.free_symbols | S.free_symbols
187
+ user = {}
188
+ if ref:
189
+ for k, val in ref.items():
190
+ user[sp.Symbol(k) if isinstance(k, str) else k] = val
191
+ out = {}
192
+ for sym in syms:
193
+ if sym in user:
194
+ out[sym] = user[sym]
195
+ elif str(sym) in _DEFAULT_REF:
196
+ out[sym] = _DEFAULT_REF[str(sym)]
197
+ else:
198
+ out[sym] = sp.Rational(1, 10) # neutral nonzero default
199
+ return out
200
+
201
+
202
+ def ground_state(H, S, ref=None, subs=None):
203
+ """Ground state of ``H c = E S c``.
204
+
205
+ **Symbolic** (``subs=None``, default): solves the characteristic polynomial
206
+ for a SMALL block (2x2, 3x3). Returns ``(E, c)`` as SymPy expressions, ``E``
207
+ simplified, ``c`` the (un-normalized) ground eigenvector (the metric is
208
+ applied later by :func:`chirgwin_coulson`, and simplifying an eigenvector
209
+ full of nested radicals is expensive, so we don't). The ground root is the
210
+ one numerically lowest at the reference point ``ref`` -- a ``{symbol-or-name:
211
+ value}`` dict; defaults are ``h=-1, s=0`` and any electron-repulsion integral
212
+ ``= 1``. The symbolic solve is only practical for a few dimensions.
213
+
214
+ **Numeric** (``subs`` given, a substitution dict): the matrices are
215
+ evaluated and the lowest eigenpair is found with ``scipy.linalg.eigh`` --
216
+ use this for anything larger, e.g. a full determinant (FCI) basis. Returns
217
+ ``(E_float, c_ndarray)``.
218
+ """
219
+ if subs is not None:
220
+ import numpy as np
221
+ from scipy.linalg import eigh as _eigh
222
+ Hn = np.array(sp.Matrix(H).subs(subs).tolist(), float)
223
+ Sn = np.array(sp.Matrix(S).subs(subs).tolist(), float)
224
+ w, v = _eigh(Hn, Sn, subset_by_index=[0, 0])
225
+ return float(w[0]), v[:, 0]
226
+ H = sp.Matrix(H)
227
+ S = sp.Matrix(S)
228
+ E = sp.Dummy('E')
229
+ roots = sp.solve((H - E * S).det(), E)
230
+ if not roots:
231
+ raise ValueError("det(H - E S) = 0 has no roots solvable in closed form")
232
+ sub = _ref_subs(H, S, ref)
233
+ E_gs = min(roots, key=lambda r: float(sp.re(r.subs(sub))))
234
+ null = (H - E_gs * S).nullspace()
235
+ if not null:
236
+ raise ValueError("no eigenvector found for the ground root")
237
+ return sp.simplify(E_gs), null[0]
238
+
239
+
240
+ # --------------------------------------------------------------------------
241
+ # the System facade
242
+ # --------------------------------------------------------------------------
243
+ class System:
244
+ """A :class:`Molecule` plus a determinant (or structure) basis.
245
+
246
+ Construct directly with ``System(molecule, basis)``, from a structure list
247
+ with ``System.from_structures(molecule, structures)``, or from a topology
248
+ with ``System.ring(L)`` / ``System.chain(n)``.
249
+ """
250
+
251
+ def __init__(self, molecule, basis, two_electron=True):
252
+ self.m = molecule
253
+ self.basis = list(basis)
254
+ self.det_strings = [_det_string(b) for b in self.basis]
255
+ self.two_electron = two_electron
256
+ self._H = None
257
+ self._S = None
258
+
259
+ # ---- constructors ----
260
+ @classmethod
261
+ def from_structures(cls, molecule, structures, two_electron=True):
262
+ """A System whose basis is an explicit list of VB structures."""
263
+ return cls(molecule, structures, two_electron=two_electron)
264
+
265
+ @classmethod
266
+ def ring(cls, L, n_alpha=None, n_beta=None, hubbard=True,
267
+ two_electron=True, **kw):
268
+ """Cyclic ``L``-orbital ring with Hubbard ``U``.
269
+
270
+ Defaults to ``floor(L/2)`` electrons of each spin (the Sz=0 reference,
271
+ e.g. 3 + 3 for benzene). For an ion or an odd ring pass ``n_alpha`` /
272
+ ``n_beta`` explicitly (the cyclopentadienyl anion is
273
+ ``System.ring(5, n_alpha=3, n_beta=3)``).
274
+ """
275
+ m = Molecule.ring(L, hubbard=hubbard, **kw)
276
+ na = L // 2 if n_alpha is None else n_alpha
277
+ nb = na if n_beta is None else n_beta
278
+ return cls(m, generate_dets(na, nb, L), two_electron=two_electron)
279
+
280
+ @classmethod
281
+ def chain(cls, n, n_alpha=None, n_beta=None, hubbard=True,
282
+ two_electron=True, **kw):
283
+ """Linear ``n``-orbital chain with Hubbard ``U``; same filling convention
284
+ as :meth:`ring` (``floor(n/2)`` electrons of each spin by default)."""
285
+ m = Molecule.chain(n, hubbard=hubbard, **kw)
286
+ na = n // 2 if n_alpha is None else n_alpha
287
+ nb = na if n_beta is None else n_beta
288
+ return cls(m, generate_dets(na, nb, n), two_electron=two_electron)
289
+
290
+ # ---- matrices ----
291
+ def hamiltonian(self):
292
+ """``(H, S)`` over the basis (cached). The 2e block is folded into H
293
+ unless the System was built with ``two_electron=False``."""
294
+ if self._H is None:
295
+ self._H, self._S = hamiltonian(self.m, self.basis,
296
+ two_electron=self.two_electron)
297
+ return self._H, self._S
298
+
299
+ @property
300
+ def H(self):
301
+ return self.hamiltonian()[0]
302
+
303
+ @property
304
+ def S(self):
305
+ return self.hamiltonian()[1]
306
+
307
+ # ---- VB structures ----
308
+ def structure_vector(self, structure):
309
+ """Column vector of ``structure`` over this System's determinant basis."""
310
+ return structure_vector(structure, self.det_strings)
311
+
312
+ # ---- solving ----
313
+ def ground_state(self, ref=None, subs=None):
314
+ """``(E, c)`` of the ground state over this basis.
315
+
316
+ Symbolic for small bases; pass ``subs`` (a numeric substitution dict) to
317
+ solve a large/FCI basis numerically with scipy (returns floats/arrays).
318
+ """
319
+ H, S = self.hamiltonian()
320
+ return ground_state(H, S, ref=ref, subs=subs)
321
+
322
+ def weights(self, structures=None, groups=None, ref=None, subs=None):
323
+ """Chirgwin-Coulson weights of the ground state.
324
+
325
+ With no ``structures``, weights are per basis function (optionally summed
326
+ by ``groups``). With ``structures`` (a list of ``FixedPsi``), the ground
327
+ state is projected onto that (possibly non-orthogonal) structure space
328
+ and weights are returned per structure, normalized over that space (the
329
+ composition of the part of the wavefunction the structures span).
330
+
331
+ Symbolic by default and only practical for small bases (the ground-state
332
+ solve is a symbolic characteristic polynomial). For an FCI-sized basis
333
+ pass ``subs`` (a numeric dict): the ground state is found numerically and
334
+ the weights come back as a NumPy array.
335
+ """
336
+ H, S = self.hamiltonian()
337
+ E, c = ground_state(H, S, ref=ref, subs=subs)
338
+ if subs is None:
339
+ S_eff = S
340
+ else:
341
+ import numpy as np
342
+ S_eff = np.array(sp.Matrix(S).subs(subs).tolist(), float)
343
+ if structures is None:
344
+ return chirgwin_coulson(c, S_eff, groups=groups)
345
+ V = sp.Matrix.hstack(*[self.structure_vector(st) for st in structures])
346
+ if subs is None:
347
+ G = V.T * S_eff * V # structure-space metric
348
+ a = G.solve(V.T * S_eff * c) # ground state in the structure basis
349
+ else:
350
+ import numpy as np
351
+ Vn = np.array(V, float)
352
+ G = Vn.T @ S_eff @ Vn
353
+ a = np.linalg.solve(G, Vn.T @ S_eff @ np.asarray(c, float))
354
+ return chirgwin_coulson(a, G, groups=groups)
@@ -0,0 +1,292 @@
1
+ """Determinant-convention consistency between standardize_det and the
2
+ generate_dets/generate_det_strings basis.
3
+
4
+ standardize_det fixes the spin *pattern* (uLuL... + majority-spin tail) but
5
+ its pairwise flips can permute the alphabetical order within the alpha/beta
6
+ blocks, so on unequal-filling (and beta-block-leading) determinants its output
7
+ string need not be a member of the generated basis (e.g. 'abcAB' -> 'aAcBb',
8
+ not 'aAbBc'). Its output convention is load-bearing in matrix construction and
9
+ must not change; the *lookup* side (symvb.system.structure_vector) therefore
10
+ standardizes both the basis strings and the structure determinants through the
11
+ full canonical map (spin pattern + within-block sort, fermion signs folded).
12
+ These tests pin the census of the mismatch, the fixed lookup at every affected
13
+ filling, sign correctness against an independent inversion-parity reference,
14
+ and exact no-regression on previously-working columns.
15
+ """
16
+ import string
17
+ import unittest
18
+ from itertools import combinations
19
+
20
+ import numpy as np
21
+ import sympy as sp
22
+
23
+ from symvb import FixedPsi, Molecule
24
+ from symvb.fixed_psi import generate_dets
25
+ from symvb.functions import generate_det_strings, standardize_det
26
+ from symvb.system import _standardize_full, hamiltonian, structure_vector
27
+
28
+ FILLINGS = [(2, 1, 4), (2, 2, 3), (2, 2, 4), (3, 2, 4), (3, 2, 6), (3, 3, 6)]
29
+
30
+ # dets (with alphabetically sorted alpha and beta blocks, i.e. exactly the
31
+ # strings FixedPsi.canonicalize can emit) whose bare standardize_det output
32
+ # is NOT in generate_det_strings(Na, Nb, Norb)
33
+ KNOWN_MISS_COUNTS = {(2, 1, 4): 0, (2, 2, 3): 9, (2, 2, 4): 36,
34
+ (3, 2, 4): 48, (3, 2, 6): 600, (3, 3, 6): 2800}
35
+
36
+
37
+ def _canonicalized_space(Na, Nb, Norb):
38
+ """All det strings with sorted alpha block and sorted beta block, over all
39
+ C(Na+Nb, Na) spin patterns: the input space structure_vector feeds into
40
+ the standardization after FixedPsi.canonicalize()."""
41
+ lo = string.ascii_lowercase[:Norb]
42
+ up = string.ascii_uppercase[:Norb]
43
+ out = []
44
+ for a in combinations(lo, Na):
45
+ for b in combinations(up, Nb):
46
+ for pos in combinations(range(Na + Nb), Na):
47
+ s = [''] * (Na + Nb)
48
+ ia = ib = 0
49
+ for i in range(Na + Nb):
50
+ if i in pos:
51
+ s[i] = a[ia]
52
+ ia += 1
53
+ else:
54
+ s[i] = b[ib]
55
+ ib += 1
56
+ out.append(''.join(s))
57
+ return out
58
+
59
+
60
+ def _ref_canonical(det_string, basis_by_soset):
61
+ """Independent reference: the basis string over the same spin-orbital set
62
+ and the inversion parity of the permutation relating the two creation
63
+ orders."""
64
+ so = [(c.lower(), 0 if c.islower() else 1) for c in det_string]
65
+ tgt = basis_by_soset[frozenset(so)]
66
+ tso = [(c.lower(), 0 if c.islower() else 1) for c in tgt]
67
+ pos = {v: i for i, v in enumerate(tso)}
68
+ idx = [pos[v] for v in so]
69
+ inv = sum(1 for i in range(len(idx)) for j in range(i + 1, len(idx))
70
+ if idx[i] > idx[j])
71
+ return tgt, (-1) ** inv
72
+
73
+
74
+ def _soset_index(basis):
75
+ return {frozenset((c.lower(), 0 if c.islower() else 1) for c in b): b
76
+ for b in basis}
77
+
78
+
79
+ def _old_structure_vector(structure, basis_dets):
80
+ """The pre-fix structure_vector algorithm (bare standardize_det lookup),
81
+ kept here as the no-regression reference on fillings where it worked."""
82
+ fp = FixedPsi(structure)
83
+ fp.canonicalize()
84
+ idx = {d: i for i, d in enumerate(basis_dets)}
85
+ v = sp.zeros(len(basis_dets), 1)
86
+ for d, c in fp:
87
+ std, flips = standardize_det(d.det_string)
88
+ if std not in idx:
89
+ raise ValueError(std)
90
+ v[idx[std]] += (-1) ** flips * c
91
+ return v
92
+
93
+
94
+ class TestFillingCensus(unittest.TestCase):
95
+ def test_standardize_det_miss_counts(self):
96
+ # pin the characterization: standardize_det's output leaves the
97
+ # generated basis exactly this often, per filling, over the
98
+ # canonicalized (sorted-block) input space; it is a no-op on the
99
+ # basis strings themselves.
100
+ for Na, Nb, Norb in FILLINGS:
101
+ basis = set(generate_det_strings(Na, Nb, Norb))
102
+ for b in basis:
103
+ self.assertEqual(standardize_det(b), (b, 0))
104
+ miss = sum(1 for d in _canonicalized_space(Na, Nb, Norb)
105
+ if standardize_det(d)[0] not in basis)
106
+ self.assertEqual(miss, KNOWN_MISS_COUNTS[(Na, Nb, Norb)],
107
+ msg=f"filling ({Na},{Nb},{Norb})")
108
+
109
+ def test_full_standardization_lands_in_basis_with_correct_sign(self):
110
+ # the fixed lookup map: every canonicalized-space det lands ON the
111
+ # basis, with the sign of the independent inversion-parity reference
112
+ for Na, Nb, Norb in FILLINGS:
113
+ basis = generate_det_strings(Na, Nb, Norb)
114
+ bset = set(basis)
115
+ ref_idx = _soset_index(basis)
116
+ for d in _canonicalized_space(Na, Nb, Norb):
117
+ key, sgn = _standardize_full(d)
118
+ self.assertIn(key, bset,
119
+ msg=f"({Na},{Nb},{Norb}): {d!r} -> {key!r}")
120
+ self.assertEqual((key, sgn), _ref_canonical(d, ref_idx),
121
+ msg=f"({Na},{Nb},{Norb}): {d!r}")
122
+
123
+ def test_structure_vector_lookup_succeeds_at_all_fillings(self):
124
+ # end-to-end: structure_vector places every sampled det (including
125
+ # the ones bare standardize_det mishandles) at the right position
126
+ # with the right sign, and never raises
127
+ for Na, Nb, Norb in FILLINGS:
128
+ basis = generate_det_strings(Na, Nb, Norb)
129
+ ref_idx = _soset_index(basis)
130
+ space = _canonicalized_space(Na, Nb, Norb)
131
+ bset = set(basis)
132
+ missing = [d for d in space if standardize_det(d)[0] not in bset]
133
+ step = max(1, len(space) // 20)
134
+ sample = space[::step] + missing[::max(1, len(missing) // 20)]
135
+ for d in sample:
136
+ v = structure_vector(FixedPsi(d), basis)
137
+ key, sgn = _ref_canonical(d, ref_idx)
138
+ nz = {basis[i]: v[i] for i in range(len(basis)) if v[i] != 0}
139
+ self.assertEqual(nz, {key: sgn},
140
+ msg=f"({Na},{Nb},{Norb}): {d!r}")
141
+
142
+ def test_outside_basis_raises_named_valueerror(self):
143
+ basis = generate_det_strings(2, 1, 4) # orbitals a..d only
144
+ with self.assertRaises(ValueError) as cm:
145
+ structure_vector(FixedPsi('aEb'), basis)
146
+ self.assertIn('aEb', str(cm.exception))
147
+
148
+ def test_duplicate_basis_det_raises(self):
149
+ # 'abA' is the same determinant as 'aAb' in a different creation order
150
+ with self.assertRaises(ValueError):
151
+ structure_vector(FixedPsi('aAb'), ['aAb', 'abA'])
152
+
153
+
154
+ class TestHoleBasisRoundTrip326(unittest.TestCase):
155
+ """(H2)3+ sigma-hole diabatics on the (3,2,6) basis: structure_vector must
156
+ reproduce the low-level to_standard mapping that
157
+ examples/h2h2h2_plus_diabatic.py hand-rolls (positions AND signs)."""
158
+
159
+ ORBS = 'abcdef'
160
+ PAIRS = [('a', 'b'), ('c', 'd'), ('e', 'f')]
161
+
162
+ def _to_standard(self, det_string):
163
+ # replicated from examples/h2h2h2_plus_diabatic.py
164
+ so_list = [2 * self.ORBS.index(c.lower()) + (0 if c.islower() else 1)
165
+ for c in det_string]
166
+ if len(set(so_list)) != len(so_list):
167
+ return None, 0
168
+ alphas = sorted(c for c in det_string if c.islower())
169
+ betas = sorted(c for c in det_string if c.isupper())
170
+ std = ''
171
+ na, nb = len(alphas), len(betas)
172
+ for i in range(min(na, nb)):
173
+ std += alphas[i] + betas[i]
174
+ std += ''.join(alphas[nb:]) + ''.join(betas[na:])
175
+ target = [2 * self.ORBS.index(c.lower()) + (0 if c.islower() else 1)
176
+ for c in std]
177
+ pos = {v: i for i, v in enumerate(target)}
178
+ idx = [pos[v] for v in so_list]
179
+ inv = sum(1 for i in range(len(idx)) for j in range(i + 1, len(idx))
180
+ if idx[i] > idx[j])
181
+ return std, (-1 if inv % 2 else 1)
182
+
183
+ def _hole_raws(self, hole_pair):
184
+ hole_atoms = self.PAIRS[hole_pair]
185
+ full_idx = [i for i in range(3) if i != hole_pair]
186
+ f0, f1 = self.PAIRS[full_idx[0]], self.PAIRS[full_idx[1]]
187
+ return [h_a + f0a + f0b.upper() + f1a + f1b.upper()
188
+ for h_a in hole_atoms
189
+ for f0a in f0 for f0b in f0
190
+ for f1a in f1 for f1b in f1]
191
+
192
+ def test_hole_diabatics_match_to_standard_route(self):
193
+ basis = generate_det_strings(3, 2, 6)
194
+ ds_to_idx = {d: i for i, d in enumerate(basis)}
195
+ cols = []
196
+ n_outside = 0
197
+ for hp in range(3):
198
+ raws = self._hole_raws(hp)
199
+ self.assertEqual(len(raws), 32)
200
+ n_outside += sum(1 for r in raws
201
+ if standardize_det(r)[0] not in ds_to_idx)
202
+ v_ref = np.zeros(len(basis))
203
+ for raw in raws:
204
+ std, sgn = self._to_standard(raw)
205
+ self.assertIsNotNone(std)
206
+ v_ref[ds_to_idx[std]] += sgn
207
+ fp = FixedPsi()
208
+ for raw in raws:
209
+ fp.add_str_det(raw, coef=1)
210
+ v = np.array(structure_vector(fp, basis), float).ravel()
211
+ self.assertTrue(np.array_equal(v, v_ref), msg=f"hole {hp}")
212
+ cols.append(v)
213
+ # the audited mismatch: 64 of the 96 raw hole dets are exactly the
214
+ # strings the pre-fix lookup raised on
215
+ self.assertEqual(n_outside, 64)
216
+ # the three diabatics are orthonormal at s = 0 (norm^2 = 32 raw dets
217
+ # collapsing pairwise onto 32 basis dets of unit coefficient)
218
+ Phi = np.column_stack(cols)
219
+ self.assertTrue(np.allclose(Phi.T @ Phi / 32.0, np.eye(3),
220
+ atol=1e-14))
221
+
222
+
223
+ class TestNoRegression(unittest.TestCase):
224
+ """Columns the pre-fix code produced correctly must be reproduced exactly."""
225
+
226
+ def test_223_longbond_column_exact(self):
227
+ # the (2,2,3) long-bond column pinned by TestStructureVector in
228
+ # test_system.py, and the full old-vs-new comparison on all three
229
+ # allyl Rumer structures
230
+ basis = [p.dets[0].det_string for p in generate_dets(2, 2, 3)]
231
+ structs = [FixedPsi('aBcC', coupled_pairs=[(0, 1)]),
232
+ FixedPsi('aAbC', coupled_pairs=[(2, 3)]),
233
+ FixedPsi('abBC', coupled_pairs=[(0, 3)])]
234
+ for st in structs:
235
+ self.assertEqual(structure_vector(st, basis),
236
+ _old_structure_vector(st, basis))
237
+ v = structure_vector(structs[2], basis)
238
+ nz = {basis[i]: v[i] for i in range(len(basis)) if v[i] != 0}
239
+ self.assertEqual(nz, {'aBbC': -1, 'bAcB': -1})
240
+
241
+ def test_214_hole_columns_exact(self):
242
+ # (2,1,4) is a filling where bare standardize_det never leaves the
243
+ # basis (census count 0), so the old algorithm was fully correct
244
+ # there; the new lookup must agree determinant-for-determinant
245
+ basis = [p.dets[0].det_string for p in generate_dets(2, 1, 4)]
246
+ hole0 = FixedPsi()
247
+ for ha in 'ab':
248
+ for fa in 'cd':
249
+ for fb in 'CD':
250
+ hole0.add_str_det(ha + fa + fb, coef=1)
251
+ hole1 = FixedPsi()
252
+ for ha in 'cd':
253
+ for fa in 'ab':
254
+ for fb in 'AB':
255
+ hole1.add_str_det(fa + fb + ha, coef=1)
256
+ for st in [hole0, hole1, FixedPsi('acC'), FixedPsi('aCc')]:
257
+ self.assertEqual(structure_vector(st, basis),
258
+ _old_structure_vector(st, basis))
259
+
260
+
261
+ class TestProjectionConsistency(unittest.TestCase):
262
+ def test_vthv_matches_direct_hamiltonian_unequal_filling(self):
263
+ # (H2)2+ dimer, Na=2/Nb=1/4 orbitals: projecting the FCI (H, S)
264
+ # through structure_vector columns must equal (H, S) built directly
265
+ # over the structures
266
+ m = Molecule(zero_ii=True, interacting_orbs=['ab', 'cd', 'bc'],
267
+ subst={'h': ('H_ab', 'H_cd'), 't': ('H_bc',),
268
+ 's': ('S_ab', 'S_cd'), 'sg': ('S_bc',)},
269
+ subst_2e={'U': ('1111',)}, max_2e_centers=1)
270
+ dets = generate_dets(2, 1, 4)
271
+ strs = [p.dets[0].det_string for p in dets]
272
+ hole0 = FixedPsi()
273
+ for ha in 'ab':
274
+ for fa in 'cd':
275
+ for fb in 'CD':
276
+ hole0.add_str_det(ha + fa + fb, coef=1)
277
+ hole1 = FixedPsi()
278
+ for ha in 'cd':
279
+ for fa in 'ab':
280
+ for fb in 'AB':
281
+ hole1.add_str_det(fa + fb + ha, coef=1)
282
+ structs = [hole0, hole1]
283
+
284
+ Hfci, Sfci = hamiltonian(m, dets)
285
+ V = sp.Matrix.hstack(*[structure_vector(st, strs) for st in structs])
286
+ H_dir, S_dir = hamiltonian(m, structs)
287
+ self.assertTrue(sp.simplify(V.T * Hfci * V - H_dir).is_zero_matrix)
288
+ self.assertTrue(sp.simplify(V.T * Sfci * V - S_dir).is_zero_matrix)
289
+
290
+
291
+ if __name__ == '__main__':
292
+ unittest.main()