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/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ from symvb.slaterdet import SlaterDet
2
+ from symvb.fixed_psi import FixedPsi
3
+ from symvb.molecule import Molecule
4
+ from symvb import symmetry
5
+ from symvb import spin
6
+ from symvb import huckel
7
+ from symvb import mo_projection
8
+ from symvb import operators
9
+ from symvb import system
10
+ from symvb.system import (System, hamiltonian, ground_state,
11
+ chirgwin_coulson, structure_vector)
12
+ from symvb.mo_projection import verify_eigenpair, EigenpairResidualError
13
+
14
+ import logging
15
+
16
+ logging.basicConfig(format='%(levelname)-8s: %(message)s')
17
+
18
+ __version__ = "2.0.0"
symvb/_o2_benchmark.py ADDED
@@ -0,0 +1,163 @@
1
+ """
2
+ Benchmark the two o2_det implementations on a fixed system battery.
3
+
4
+ Runs each (system, method) combination N_TRIALS times, reports the median
5
+ of wall-time and peak memory, plus the total SymPy-op count of the
6
+ resulting matrix as a proxy for output expression complexity.
7
+
8
+ Usage:
9
+ cd vbt-3-repo
10
+ PYTHONPATH=. python3 -m symvb._o2_benchmark
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import gc
15
+ import time
16
+ import tracemalloc
17
+
18
+ import sympy as sp
19
+
20
+ from symvb import Molecule
21
+
22
+
23
+ # --- Cases ---------------------------------------------------------------
24
+
25
+ _PPP_2E = {'U': ('1111',), 'J': ('1212',), 'K': ('1122',),
26
+ 'M': ('1112', '1121', '1222')}
27
+
28
+
29
+ def _h2(cutoff):
30
+ return dict(
31
+ label='H2',
32
+ Na=1, Nb=1, Norbs=2,
33
+ kwargs=dict(subst_2e=_PPP_2E,
34
+ interacting_orbs=['ab'],
35
+ max_2e_centers=cutoff),
36
+ )
37
+
38
+
39
+ def _allyl(cutoff):
40
+ return dict(
41
+ label='allyl-3c4e',
42
+ Na=2, Nb=2, Norbs=3,
43
+ kwargs=dict(subst_2e=_PPP_2E,
44
+ interacting_orbs=['ab', 'bc'],
45
+ max_2e_centers=cutoff),
46
+ )
47
+
48
+
49
+ def _benzene_ppp():
50
+ return dict(
51
+ label='benzene-PPP',
52
+ Na=3, Nb=3, Norbs=6,
53
+ kwargs=dict(
54
+ zero_ii=True,
55
+ interacting_orbs=['ab', 'bc', 'cd', 'de', 'ef', 'af'],
56
+ subst={'h': ('H_ab', 'H_bc', 'H_cd', 'H_de', 'H_ef', 'H_af'),
57
+ 's': ('S_ab', 'S_bc', 'S_cd', 'S_de', 'S_ef', 'S_af')},
58
+ subst_2e=_PPP_2E,
59
+ max_2e_centers=2,
60
+ ),
61
+ )
62
+
63
+
64
+ CASES = [
65
+ (_h2(cutoff=1), 5),
66
+ (_h2(cutoff=2), 5),
67
+ (_allyl(cutoff=1), 5),
68
+ (_allyl(cutoff=2), 5),
69
+ (_allyl(cutoff=3), 3),
70
+ (_allyl(cutoff=4), 3),
71
+ (_benzene_ppp(), 1), # 1 trial; if blocked is fast we can rerun with more
72
+ ]
73
+
74
+
75
+ # --- Bench runner --------------------------------------------------------
76
+
77
+ def _build_matrix_o2(method, case):
78
+ m = Molecule(o2_method=method, **case['kwargs'])
79
+ m.generate_basis(case['Na'], case['Nb'], case['Norbs'])
80
+ return m.o2_matrix(m.basis)
81
+
82
+
83
+ def _count_ops(matrix):
84
+ return sum(int(matrix[i, j].count_ops())
85
+ for i in range(matrix.rows) for j in range(matrix.cols))
86
+
87
+
88
+ def _trial(method, case):
89
+ gc.collect()
90
+ tracemalloc.start()
91
+ t0 = time.perf_counter()
92
+ H2 = _build_matrix_o2(method, case)
93
+ wall = time.perf_counter() - t0
94
+ _, peak = tracemalloc.get_traced_memory()
95
+ tracemalloc.stop()
96
+ ops = _count_ops(H2)
97
+ n = H2.rows
98
+ return dict(wall=wall, peak_mb=peak / (1024 * 1024), ops=ops, dim=n)
99
+
100
+
101
+ def _median(xs):
102
+ xs = sorted(xs)
103
+ n = len(xs)
104
+ if n == 0:
105
+ return float('nan')
106
+ if n % 2 == 1:
107
+ return xs[n // 2]
108
+ return 0.5 * (xs[n // 2 - 1] + xs[n // 2])
109
+
110
+
111
+ def benchmark():
112
+ print('=' * 78)
113
+ print('Two-electron matrix benchmark: direct vs blocked')
114
+ print('=' * 78)
115
+ print()
116
+ header = ('| %-15s | %-7s | %-7s | dim | wall (s) | peak (MB) '
117
+ '| ops | speedup |')
118
+ sep = ('|-----------------|---------|---------|-------'
119
+ '|------------|------------|----------|---------|')
120
+ print(header % ('system', 'cutoff', 'method'))
121
+ print(sep)
122
+ for case, n_trials in CASES:
123
+ cutoff = case['kwargs'].get('max_2e_centers', 4)
124
+ results = {}
125
+ for method in ('direct', 'blocked'):
126
+ print(' ... %s cutoff=%d %s (n=%d)'
127
+ % (case['label'], cutoff, method, n_trials), flush=True)
128
+ trials = []
129
+ for ti in range(n_trials):
130
+ t = _trial(method, case)
131
+ trials.append(t)
132
+ print(' trial %d: wall=%.3fs peak=%.1fMB'
133
+ % (ti, t['wall'], t['peak_mb']), flush=True)
134
+ results[method] = dict(
135
+ wall=_median([t['wall'] for t in trials]),
136
+ peak_mb=_median([t['peak_mb'] for t in trials]),
137
+ ops=trials[0]['ops'],
138
+ dim=trials[0]['dim'],
139
+ )
140
+ # Print rows; speedup column shown on the blocked row only.
141
+ for method in ('direct', 'blocked'):
142
+ r = results[method]
143
+ speedup = ''
144
+ if method == 'blocked' and results['direct']['wall'] > 0:
145
+ speedup = '%.2fx' % (results['direct']['wall'] / r['wall'])
146
+ print('| %-15s | %-7d | %-7s | %-5d | %-10.3f | %-10.2f '
147
+ '| %-8d | %-7s |'
148
+ % (case['label'], cutoff, method, r['dim'],
149
+ r['wall'], r['peak_mb'], r['ops'], speedup))
150
+ # Sanity: ops should agree (modulo chemist-symmetry symbol naming).
151
+ ops_diff = results['direct']['ops'] - results['blocked']['ops']
152
+ if ops_diff != 0:
153
+ print('| ^ ops differ by %+d (chemist-symbol naming, not '
154
+ 'a correctness issue)' % ops_diff)
155
+ print()
156
+ print('Notes:')
157
+ print(' - wall and peak_mb are medians across N_TRIALS runs.')
158
+ print(' - ops = sum of sympy.count_ops() over all matrix entries.')
159
+ print(' - speedup > 1 means blocked is faster.')
160
+
161
+
162
+ if __name__ == '__main__':
163
+ benchmark()
symvb/_o2_blocked.py ADDED
@@ -0,0 +1,301 @@
1
+ """
2
+ Spin-block-aware two-electron matrix element (Phase 1.5: half-pair cache).
3
+
4
+ The historical o2_det in molecule.py expands <D_A | H_2 | D_B> via Loewdin's
5
+ cofactor formula on the full spin-mixed (N-2)-electron overlap and recomputes
6
+ that overlap determinant for every (i, k, j, l) electron-position quadruple.
7
+ Because the AO overlap is spin-diagonal, the (N-2)-electron overlap is itself
8
+ block-diagonal in spin: it factors into per-spin-block determinants. Exploiting
9
+ that structure lets the matrix element be assembled from a small per-pair
10
+ precompute (per-spin-block 1-row and 2-row cofactor tables) plus three spin-
11
+ channel contractions (alpha-alpha, beta-beta, alpha-beta).
12
+
13
+ Half-pair caching (Phase 1.5)
14
+ -----------------------------
15
+ Per-spin-block cofactor tables depend only on the half-string of D_A and the
16
+ half-string of D_B for that spin. Across the basis, many (D_A, D_B) pairs
17
+ share their (alpha-half-pair, beta-half-pair) decomposition: for benzene
18
+ 3a+3b in 6 orbitals, there are C(6,3)^2 = 400 dets but only C(6,3)^2 = 400
19
+ distinct alpha-half-strings and 400 distinct beta-half-strings, hence at
20
+ most 400 * 400 = 160,000 (D_A, D_B) full-pair entries spread across only
21
+ 20*20 = 400 unique alpha-half-pairs and 400 unique beta-half-pairs. The
22
+ per-half-pair cofactor tables are therefore evaluated once and reused
23
+ ~400-fold rather than rebuilt at every full-pair call.
24
+
25
+ The cache is lazy: built on demand inside o2_det_blocked, keyed on the
26
+ sorted (alphabetised-within-block) half-strings, attached to the Molecule
27
+ instance as ._o2_blocked_cache. It populates as the caller walks the basis
28
+ and hits all needed entries naturally during one m.o2_matrix(m.basis) sweep.
29
+
30
+ Algorithm
31
+ ---------
32
+ With the user's det_string in possibly-arbitrary creation order, fold the
33
+ parities of (i) the spin-block permutation, (ii) the within-alpha sort,
34
+ and (iii) the within-beta sort into an overall sign and work in canonical
35
+ layout (alpha block first sorted alphabetically, then beta block likewise).
36
+ For the canonical layout:
37
+
38
+ - alpha-alpha pair: D^{AB}_{ik,jl} = det(S_beta) * Gamma^alpha_{ik,jl}
39
+ - beta-beta pair: D^{AB}_{ik,jl} = det(S_alpha) * Gamma^beta_{ik,jl}
40
+ - alpha-beta pair: D^{AB}_{ik,jl} = gamma^alpha_{ij} * gamma^beta_{kl}
41
+
42
+ where gamma^sigma_{ij} = (-1)^{i+j} det(S^sigma with row i col j removed) and
43
+ Gamma^sigma_{ik,jl} = (-1)^{i+k+j+l} det(S^sigma with rows i,k cols j,l
44
+ removed). The cofactor tables are looked up from the half-pair cache;
45
+ the contraction loops then sum over per-block index ranges directly.
46
+
47
+ The Phase 2 agreement-test harness in symvb/test_o2_agreement.py validates
48
+ symbolic parity with the direct path on a battery of cases; that harness
49
+ re-runs unchanged after this refactor because the algorithmic content is
50
+ identical.
51
+ """
52
+ from __future__ import annotations
53
+
54
+ import sympy as sp
55
+
56
+ from symvb.functions import sorti
57
+
58
+
59
+ _SP_ZERO = sp.Integer(0)
60
+ _SP_ONE = sp.Integer(1)
61
+
62
+
63
+ def o2_det_blocked(molecule, D1, D2):
64
+ """Two-electron matrix element via the spin-block-aware path with
65
+ half-pair caching.
66
+
67
+ Must agree symbolically with Molecule.o2_det(D1, D2) in 'direct' mode
68
+ on every input. See module docstring for algorithm.
69
+ """
70
+ # --- Sz selection ----------------------------------------------------
71
+ if D1.Nel != D2.Nel:
72
+ return _SP_ZERO
73
+ if (len(D1.alpha_string) != len(D2.alpha_string) or
74
+ len(D1.beta_string) != len(D2.beta_string)):
75
+ return _SP_ZERO
76
+
77
+ # --- Bring each det to canonical (spin-block + alphabetical within
78
+ # block) and capture all three sign contributions per det. -------
79
+ A_alpha_s, A_alpha_inv = sorti(D1.alpha_string)
80
+ A_beta_s, A_beta_inv = sorti(D1.beta_string)
81
+ B_alpha_s, B_alpha_inv = sorti(D2.alpha_string)
82
+ B_beta_s, B_beta_inv = sorti(D2.beta_string)
83
+
84
+ sigma_A = (_spin_block_parity(D1.spins) *
85
+ (1 if A_alpha_inv % 2 == 0 else -1) *
86
+ (1 if A_beta_inv % 2 == 0 else -1))
87
+ sigma_B = (_spin_block_parity(D2.spins) *
88
+ (1 if B_alpha_inv % 2 == 0 else -1) *
89
+ (1 if B_beta_inv % 2 == 0 else -1))
90
+ overall_sign = sigma_A * sigma_B
91
+
92
+ # --- Half-pair cache lookup (lazy; builds on first request) ---------
93
+ alpha_data = _get_half_pair(molecule, 'alpha', A_alpha_s, B_alpha_s)
94
+ beta_data = _get_half_pair(molecule, 'beta', A_beta_s, B_beta_s)
95
+
96
+ # Lowercase canonical strings for integral-symbol queries.
97
+ A_alpha = A_alpha_s.lower()
98
+ A_beta = A_beta_s.lower()
99
+ B_alpha = B_alpha_s.lower()
100
+ B_beta = B_beta_s.lower()
101
+
102
+ n_a = len(A_alpha)
103
+ n_b = len(A_beta)
104
+
105
+ det_S_alpha = alpha_data['det']
106
+ det_S_beta = beta_data['det']
107
+ gamma_alpha = alpha_data['gamma']
108
+ gamma_beta = beta_data['gamma']
109
+ Gamma_alpha = alpha_data['Gamma']
110
+ Gamma_beta = beta_data['Gamma']
111
+
112
+ # --- alpha-alpha channel ---------------------------------------------
113
+ # Bucket Gamma cofactors by their (int_d - int_x) integrand. Skip
114
+ # quadruples whose Gamma cofactor is structurally zero (common when
115
+ # the spin-block overlap has zero rows from non-interacting AOs):
116
+ # iv-tuple construction and integral lookup avoided entirely.
117
+ T_aa = _SP_ZERO
118
+ if n_a >= 2:
119
+ aa_groups = {}
120
+ for i in range(n_a):
121
+ for k in range(i + 1, n_a):
122
+ for j in range(n_a):
123
+ for l in range(j + 1, n_a):
124
+ cof = Gamma_alpha[(i, k, j, l)]
125
+ if cof == _SP_ZERO:
126
+ continue
127
+ iv_d = (A_alpha[i], A_alpha[k], B_alpha[j], B_alpha[l])
128
+ iv_x = (A_alpha[i], A_alpha[k], B_alpha[l], B_alpha[j])
129
+ int_d = molecule.get_o2_expr(iv_d)
130
+ int_x = molecule.get_o2_expr(iv_x)
131
+ integrand = int_d - int_x
132
+ if integrand == _SP_ZERO:
133
+ continue
134
+ prev = aa_groups.get(integrand)
135
+ aa_groups[integrand] = cof if prev is None else prev + cof
136
+ for integrand, coef in aa_groups.items():
137
+ T_aa = T_aa + integrand * coef
138
+ T_aa = T_aa * det_S_beta
139
+
140
+ # --- beta-beta channel -----------------------------------------------
141
+ T_bb = _SP_ZERO
142
+ if n_b >= 2:
143
+ bb_groups = {}
144
+ for i in range(n_b):
145
+ for k in range(i + 1, n_b):
146
+ for j in range(n_b):
147
+ for l in range(j + 1, n_b):
148
+ cof = Gamma_beta[(i, k, j, l)]
149
+ if cof == _SP_ZERO:
150
+ continue
151
+ iv_d = (A_beta[i], A_beta[k], B_beta[j], B_beta[l])
152
+ iv_x = (A_beta[i], A_beta[k], B_beta[l], B_beta[j])
153
+ int_d = molecule.get_o2_expr(iv_d)
154
+ int_x = molecule.get_o2_expr(iv_x)
155
+ integrand = int_d - int_x
156
+ if integrand == _SP_ZERO:
157
+ continue
158
+ prev = bb_groups.get(integrand)
159
+ bb_groups[integrand] = cof if prev is None else prev + cof
160
+ for integrand, coef in bb_groups.items():
161
+ T_bb = T_bb + integrand * coef
162
+ T_bb = T_bb * det_S_alpha
163
+
164
+ # --- alpha-beta channel (direct only; exchange forbidden by spin) ----
165
+ # The integral set in symvb is small (5-10 distinct symbols at typical
166
+ # PPP scale), so many AO quadruples in this 4-loop share the same
167
+ # integral. Two-tier sparse iteration:
168
+ #
169
+ # (a) Reorder loops so gamma_alpha[i][j] sits in the outer pair,
170
+ # letting us skip the entire (k, l) inner loop when gamma_alpha
171
+ # is structurally zero (common when the alpha overlap has zero
172
+ # rows from non-interacting AOs).
173
+ # (b) Same for gamma_beta inside.
174
+ #
175
+ # Then accumulate the gamma_alpha * gamma_beta products per integral-
176
+ # symbol bucket and multiply once at the end: replaces ~n_a^2 * n_b^2
177
+ # three-way SymPy multiplies with the same number of two-way multiplies
178
+ # plus a handful of final symbol-bucket multiplies.
179
+ T_ab = _SP_ZERO
180
+ if n_a >= 1 and n_b >= 1:
181
+ ab_groups = {}
182
+ for i in range(n_a):
183
+ ai = A_alpha[i]
184
+ for j in range(n_a):
185
+ gA = gamma_alpha[i][j]
186
+ if gA == _SP_ZERO:
187
+ continue
188
+ bj = B_alpha[j]
189
+ for k in range(n_b):
190
+ ak = A_beta[k]
191
+ for l in range(n_b):
192
+ gB = gamma_beta[k][l]
193
+ if gB == _SP_ZERO:
194
+ continue
195
+ iv = (ai, ak, bj, B_beta[l])
196
+ int_d = molecule.get_o2_expr(iv)
197
+ if int_d == _SP_ZERO:
198
+ continue
199
+ product = gA * gB
200
+ prev = ab_groups.get(int_d)
201
+ ab_groups[int_d] = product if prev is None else prev + product
202
+ for int_sym, coef in ab_groups.items():
203
+ T_ab = T_ab + int_sym * coef
204
+
205
+ result = overall_sign * (T_aa + T_bb + T_ab)
206
+ if result == 0:
207
+ return _SP_ZERO
208
+ return result
209
+
210
+
211
+ # --- Half-pair cache -----------------------------------------------------
212
+
213
+ def _get_half_pair(molecule, spin, A_sorted, B_sorted):
214
+ """Return cached cofactor tables for the (A_sorted, B_sorted) half-pair
215
+ in the given spin block. Builds on demand; subsequent calls hit the
216
+ cache. The cache is attached to the molecule instance and persists for
217
+ its lifetime."""
218
+ if not hasattr(molecule, '_o2_blocked_cache') or molecule._o2_blocked_cache is None:
219
+ molecule._o2_blocked_cache = {'alpha': {}, 'beta': {}}
220
+ bucket = molecule._o2_blocked_cache[spin]
221
+ key = (A_sorted, B_sorted)
222
+ hit = bucket.get(key)
223
+ if hit is not None:
224
+ return hit
225
+ S = _build_overlap(molecule, A_sorted.lower(), B_sorted.lower())
226
+ n = S.rows
227
+ entry = {
228
+ 'det': _safe_det(S),
229
+ 'gamma': _one_row_cofactor_table(S) if n >= 1 else None,
230
+ 'Gamma': _two_row_cofactor_dict(S) if n >= 2 else None,
231
+ }
232
+ bucket[key] = entry
233
+ return entry
234
+
235
+
236
+ # --- helpers -------------------------------------------------------------
237
+
238
+ def _spin_block_parity(spins):
239
+ """Sign of the permutation that brings a spin pattern (string of '+' for
240
+ alpha and '-' for beta) into canonical (all '+' first, then all '-')
241
+ order. Equals (-1)^(number of beta-before-alpha inversions)."""
242
+ inv = 0
243
+ n = len(spins)
244
+ for i in range(n):
245
+ if spins[i] == '-':
246
+ for j in range(i + 1, n):
247
+ if spins[j] == '+':
248
+ inv += 1
249
+ return 1 if inv % 2 == 0 else -1
250
+
251
+
252
+ def _build_overlap(molecule, A_orbs, B_orbs):
253
+ """Per-spin-block overlap matrix of pairwise AO overlaps. Both lists
254
+ must be lowercase spatial-orbital labels of equal length."""
255
+ n = len(A_orbs)
256
+ if n == 0:
257
+ return sp.zeros(0, 0)
258
+ return sp.Matrix(n, n, lambda i, j: molecule.get_o1_expr(A_orbs[i], B_orbs[j], 'S'))
259
+
260
+
261
+ def _safe_det(M):
262
+ """det() that returns 1 for the empty (0x0) matrix without invoking
263
+ sympy.det on it (which can be quirky on degenerate sizes)."""
264
+ if M.rows == 0:
265
+ return _SP_ONE
266
+ return M.det()
267
+
268
+
269
+ def _one_row_cofactor_table(S):
270
+ """gamma[i][j] = (-1)^{i+j} det(S with row i, col j removed)."""
271
+ n = S.rows
272
+ table = [[None] * n for _ in range(n)]
273
+ for i in range(n):
274
+ for j in range(n):
275
+ sign = 1 if (i + j) % 2 == 0 else -1
276
+ table[i][j] = sign * _minor(S, [i], [j])
277
+ return table
278
+
279
+
280
+ def _two_row_cofactor_dict(S):
281
+ """Gamma[(i,k,j,l)] = (-1)^{i+k+j+l} det(S with rows i,k, cols j,l removed)
282
+ for i<k, j<l. Keys are quadruples of block-internal indices."""
283
+ n = S.rows
284
+ table = {}
285
+ for i in range(n):
286
+ for k in range(i + 1, n):
287
+ for j in range(n):
288
+ for l in range(j + 1, n):
289
+ sign = 1 if (i + k + j + l) % 2 == 0 else -1
290
+ table[(i, k, j, l)] = sign * _minor(S, [i, k], [j, l])
291
+ return table
292
+
293
+
294
+ def _minor(S, rows_to_remove, cols_to_remove):
295
+ """Determinant of S with the given rows and columns deleted. Returns 1
296
+ for the resulting empty matrix."""
297
+ rows_keep = [r for r in range(S.rows) if r not in rows_to_remove]
298
+ cols_keep = [c for c in range(S.cols) if c not in cols_to_remove]
299
+ if not rows_keep:
300
+ return _SP_ONE
301
+ return S.extract(rows_keep, cols_keep).det()
symvb/_o2_symmetric.py ADDED
@@ -0,0 +1,124 @@
1
+ """
2
+ Symmetry-projected two-electron matrix construction.
3
+
4
+ Build H_2 directly in the totally-symmetric (orbit-sum) SALC basis, without
5
+ materialising the full determinant-basis matrix first. The infrastructure
6
+ in symvb.symmetry (apply_orbital_permutation, generate_group,
7
+ totally_symmetric_basis) supplies the orbit decomposition; this module
8
+ contracts the matrix elements using G-invariance of H_2 to skip redundant
9
+ det-pair calls.
10
+
11
+ Algorithm
12
+ ---------
13
+ A totally-symmetric SALC over orbit `orb_a` of size `s_a` is the uniform sum
14
+
15
+ psi_a = (1/sqrt(s_a)) * sum_{i in orb_a} D_i
16
+
17
+ Because H_2 commutes with every group element (a precondition the user
18
+ asserts implicitly through their `subst` and `subst_2e` dictionaries
19
+ grouping equivalent integrals), the matrix element between SALCs
20
+
21
+ <psi_a | H_2 | psi_b> = (1/sqrt(s_a s_b)) * sum_{i,j} <D_i | H_2 | D_j>
22
+
23
+ reduces via group invariance to a single sum:
24
+
25
+ <psi_a | H_2 | psi_b> = sqrt(s_a / s_b) * sum_{m in orb_b} <D_seed_a | H_2 | D_m>
26
+
27
+ where seed_a is any representative of orb_a. The double sum collapses from
28
+ s_a * s_b det-pair calls to s_b (or s_a if we pick the other side as seed).
29
+
30
+ For benzene's A_{1g} block (38 orbits, 400 dets), this drops the work from
31
+ ~80k det-pair calls (full upper-triangle build) to ~8k (single-orbit-side
32
+ sums per SALC pair).
33
+
34
+ Preconditions
35
+ -------------
36
+ 1. The user's subst dictionaries must make H_2 commute with every generator
37
+ in the supplied group. For uniformly-substituted PPP-type Hamiltonians
38
+ on regular topologies (benzene ring, allyl chain), this is automatic.
39
+ If the user uses inequivalent integrals on equivalent atoms, the
40
+ resulting block matrix will silently differ from the U^T H_full U form.
41
+
42
+ 2. `orbits` must come from `symvb.symmetry.totally_symmetric_basis(generators, N)`
43
+ on the same `full_basis` length. Signed orbit sums (from
44
+ `signed_totally_symmetric_basis`) are not supported in this routine
45
+ yet -- the formula above assumes uniform +1 weights.
46
+
47
+ Validation against the full build is done by
48
+ test_o2_symmetric.TestSymmetricO2Agreement on small systems where both
49
+ paths are tractable: it constructs the full o2_matrix, projects via
50
+ U^T H U using the unsigned projector, and compares element-wise against
51
+ the direct symmetric build.
52
+ """
53
+ from __future__ import annotations
54
+
55
+ import sympy as sp
56
+
57
+
58
+ def o2_matrix_symmetric(molecule, full_basis, orbits):
59
+ """Build the o2 matrix in the totally-symmetric (orbit-sum) SALC basis.
60
+
61
+ Parameters
62
+ ----------
63
+ molecule : symvb.Molecule
64
+ Provides o2_det / o2 (works in either 'direct' or 'blocked' o2_method).
65
+ full_basis : list of FixedPsi
66
+ The full Sz-sector basis, typically `m.basis` after
67
+ `m.generate_basis(Na, Nb, Norbs)`.
68
+ orbits : list of list of int
69
+ Orbit decomposition of full_basis under the symmetry group.
70
+ Output of `symvb.symmetry.totally_symmetric_basis(generators, N)[1]`.
71
+
72
+ Returns
73
+ -------
74
+ H : sympy.Matrix of shape (k, k) with k = len(orbits).
75
+ Element [a, b] equals <psi_a | H_2 | psi_b> where psi_a is the
76
+ unit-normalised orbit-sum SALC for orbit a. Equals U^T @ H_full @ U
77
+ with U from totally_symmetric_basis.
78
+
79
+ Notes
80
+ -----
81
+ The symmetric upper triangle is computed and mirrored. Each off-diagonal
82
+ pair costs min(|orb_a|, |orb_b|) det-pair calls; the diagonal costs
83
+ |orb_a|. For benzene A_{1g} (38 orbits, mean orbit size ~10): ~8k
84
+ det-pair calls total versus ~80k for the full upper-triangle basis
85
+ build.
86
+ """
87
+ k = len(orbits)
88
+ H = sp.zeros(k)
89
+ sizes = [len(orb) for orb in orbits]
90
+
91
+ # Cache the seed SlaterDet for each orbit to avoid repeated FixedPsi -> det.
92
+ def _seed_det(idx):
93
+ fp = full_basis[orbits[idx][0]]
94
+ return fp.dets[0]
95
+
96
+ seeds = [_seed_det(a) for a in range(k)]
97
+
98
+ for a in range(k):
99
+ size_a = sizes[a]
100
+ for b in range(a, k):
101
+ size_b = sizes[b]
102
+ # Pick the orbit with fewer elements for the inner sum to
103
+ # minimise det-pair calls. Both choices yield the same value
104
+ # by Hermiticity; we pick the cheaper one.
105
+ if size_b <= size_a:
106
+ # Sum over orb_b, fix seed_a; factor sqrt(size_a / size_b).
107
+ seed = seeds[a]
108
+ inner = sp.S.Zero
109
+ for m in orbits[b]:
110
+ other = full_basis[m].dets[0]
111
+ inner = inner + molecule.o2_det(seed, other)
112
+ factor = sp.sqrt(sp.Rational(size_a, size_b))
113
+ else:
114
+ # Sum over orb_a, fix seed_b; factor sqrt(size_b / size_a).
115
+ seed = seeds[b]
116
+ inner = sp.S.Zero
117
+ for m in orbits[a]:
118
+ other = full_basis[m].dets[0]
119
+ inner = inner + molecule.o2_det(other, seed)
120
+ factor = sp.sqrt(sp.Rational(size_b, size_a))
121
+ H[a, b] = factor * inner
122
+ if a != b:
123
+ H[b, a] = H[a, b]
124
+ return H
symvb/data.py ADDED
@@ -0,0 +1,24 @@
1
+ hperm = {(1, 2, 3, 4): (1, 2, 3, 4),
2
+ (3, 2, 1, 4): (1, 2, 3, 4),
3
+ (1, 4, 3, 2): (1, 2, 3, 4),
4
+ (3, 4, 1, 2): (1, 2, 3, 4),
5
+ (2, 1, 4, 3): (1, 2, 3, 4),
6
+ (4, 1, 2, 3): (1, 2, 3, 4),
7
+ (2, 3, 4, 1): (1, 2, 3, 4),
8
+ (4, 3, 2, 1): (1, 2, 3, 4),
9
+ (1, 2, 4, 3): (1, 2, 4, 3),
10
+ (4, 2, 1, 3): (1, 2, 4, 3),
11
+ (1, 3, 4, 2): (1, 2, 4, 3),
12
+ (4, 3, 1, 2): (1, 2, 4, 3),
13
+ (2, 1, 3, 4): (1, 2, 4, 3),
14
+ (3, 1, 2, 4): (1, 2, 4, 3),
15
+ (2, 4, 3, 1): (1, 2, 4, 3),
16
+ (3, 4, 2, 1): (1, 2, 4, 3),
17
+ (1, 3, 2, 4): (1, 3, 2, 4),
18
+ (2, 3, 1, 4): (1, 3, 2, 4),
19
+ (1, 4, 2, 3): (1, 3, 2, 4),
20
+ (2, 4, 1, 3): (1, 3, 2, 4),
21
+ (3, 1, 4, 2): (1, 3, 2, 4),
22
+ (4, 1, 3, 2): (1, 3, 2, 4),
23
+ (3, 2, 4, 1): (1, 3, 2, 4),
24
+ (4, 2, 3, 1): (1, 3, 2, 4)}