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/huckel.py ADDED
@@ -0,0 +1,322 @@
1
+ """
2
+ Symbolic Huckel solver.
3
+
4
+ Given a one-electron tight-binding graph (adjacency + uniform resonance
5
+ integral h, uniform overlap s), produce the molecular orbitals and
6
+ energies symbolically. The MO eigenvectors of the adjacency matrix are
7
+ simultaneously eigenvectors of the GHEP H c = eps S c whenever
8
+ S = I + (s/h) H; in that case the energies follow as
9
+
10
+ eps_k(s) = h * lambda_k / (1 + s * lambda_k), lambda_k = adjacency
11
+ eigenvalue.
12
+
13
+ This module returns those quantities in `HuckelResult`. Coefficients are
14
+ RAW integer / algebraic-number entries (no 1/sqrt(L) prefactor): the
15
+ global normalization is a scalar that cancels in any eigenvalue
16
+ identity, and rationals/surds keep SymPy fast.
17
+
18
+ Special cases are hand-coded for cyclic L in {3, 4, 5, 6} so that the
19
+ coefficient matrices agree with the manuscript scripts (matching
20
+ benzene's real-cos/sin Bloch basis, Cp's golden-ratio entries, etc).
21
+ General graphs fall through to `sympy.Matrix.eigenvects`.
22
+ """
23
+ from dataclasses import dataclass, field
24
+ from typing import Optional
25
+
26
+ import sympy
27
+
28
+
29
+ class HuckelGHEPMismatchError(ValueError):
30
+ """Raised when a user-supplied overlap matrix does not satisfy the
31
+ S = I + (s/h) H precondition that lets MOs of H simultaneously
32
+ diagonalize the GHEP."""
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class HuckelResult:
37
+ """Symbolic output of a Huckel solve.
38
+
39
+ Fields:
40
+ site_labels : tuple of site names (AO ordering used).
41
+ eigenvalues : tuple of adjacency-matrix eigenvalues lambda_k
42
+ (parameter-free sympy expressions; rationals or
43
+ algebraic numbers).
44
+ energies : tuple of MO energies eps_k(h, s) = h*lambda_k /
45
+ (1 + s*lambda_k), as sympy expressions in
46
+ (h_symbol, s_symbol).
47
+ coefficients : sympy Matrix of shape (n_mo, n_sites). Row k is
48
+ MO k expanded over the AO basis, in raw
49
+ integer/algebraic form (NOT AO-orthonormalized).
50
+ h_symbol : the sympy.Symbol for the resonance integral.
51
+ s_symbol : the sympy.Symbol for the AO overlap.
52
+ point_group : optional point-group label (e.g. 'D_6h').
53
+ irrep_labels : optional per-MO irrep tags (e.g. ('a_2u', 'e_1g',
54
+ 'e_1g', 'e_2u', 'e_2u', 'b_2g')).
55
+ """
56
+ site_labels: tuple
57
+ eigenvalues: tuple
58
+ energies: tuple
59
+ coefficients: sympy.Matrix
60
+ h_symbol: sympy.Symbol
61
+ s_symbol: sympy.Symbol
62
+ point_group: Optional[str] = None
63
+ irrep_labels: Optional[tuple] = None
64
+
65
+ def energy_of_occupation(self, occupation):
66
+ """Return the total one-electron energy for a given MO
67
+ occupation. `occupation` is an iterable of (mo_index,
68
+ n_electrons) pairs OR a flat iterable of length n_mo with
69
+ per-MO electron counts."""
70
+ occ = list(occupation)
71
+ if occ and not isinstance(occ[0], (tuple, list)):
72
+ assert len(occ) == len(self.energies)
73
+ return sympy.Add(*(n * e for n, e in zip(occ, self.energies)))
74
+ return sympy.Add(*(n * self.energies[k] for k, n in occ))
75
+
76
+
77
+ def _resolve_symbol(x, default_name):
78
+ if isinstance(x, sympy.Symbol):
79
+ return x
80
+ return sympy.Symbol(x)
81
+
82
+
83
+ def _energies_from_eigenvalues(lambdas, h, s):
84
+ return tuple(h * lam / (1 + s * lam) for lam in lambdas)
85
+
86
+
87
+ def _ring_special_case(L):
88
+ """Return (eigenvalues, coefficients, irrep_labels) for L in
89
+ {3, 4, 5, 6} with a hand-picked real Bloch basis whose entries are
90
+ integers (or, for L=5, golden-ratio surds). Coefficients are NOT
91
+ AO-orthonormalized; row scaling is chosen for memorable rationals.
92
+
93
+ Conventions match the existing manuscript example scripts
94
+ (benzene_1e_analytical_overlap.py, etc).
95
+ """
96
+ if L == 3:
97
+ # C_3 triangle. lambda = {2, -1, -1}.
98
+ # Real basis: totally symmetric, plus two e-type orthogonal vectors.
99
+ eigs = (sympy.Integer(2), sympy.Integer(-1), sympy.Integer(-1))
100
+ coeffs = sympy.Matrix([
101
+ [1, 1, 1],
102
+ [2, -1, -1],
103
+ [0, 1, -1],
104
+ ])
105
+ irreps = ('a', 'e', 'e')
106
+ return eigs, coeffs, irreps
107
+
108
+ if L == 4:
109
+ # C_4 square. lambda = {2, 0, 0, -2}.
110
+ eigs = (sympy.Integer(2), sympy.Integer(0),
111
+ sympy.Integer(0), sympy.Integer(-2))
112
+ coeffs = sympy.Matrix([
113
+ [1, 1, 1, 1],
114
+ [1, 0, -1, 0],
115
+ [0, 1, 0, -1],
116
+ [1, -1, 1, -1],
117
+ ])
118
+ irreps = ('a_g', 'e_u', 'e_u', 'b_g')
119
+ return eigs, coeffs, irreps
120
+
121
+ if L == 5:
122
+ # Pentagon. lambda = {2, 2cos(72), 2cos(72), 2cos(144), 2cos(144)}
123
+ # = {2, (sqrt(5)-1)/2, (sqrt(5)-1)/2, -(sqrt(5)+1)/2, -(sqrt(5)+1)/2}
124
+ sqrt5 = sympy.sqrt(5)
125
+ phi_inv = (sqrt5 - 1) / 2 # 2 cos(72)
126
+ neg_phi = -(sqrt5 + 1) / 2 # 2 cos(144)
127
+ eigs = (sympy.Integer(2), phi_inv, phi_inv, neg_phi, neg_phi)
128
+ # Real Bloch coefficients: cos(2 pi m j / 5) and sin(2 pi m j / 5).
129
+ # Using sympy.cos/sin then simplifying gives surds in Q[sqrt(5)].
130
+ coeffs_rows = [[sympy.Integer(1)] * 5]
131
+ for m in (1, 2):
132
+ cos_row = [sympy.simplify(sympy.cos(2 * sympy.pi * m * j / 5))
133
+ for j in range(5)]
134
+ sin_row = [sympy.simplify(sympy.sin(2 * sympy.pi * m * j / 5))
135
+ for j in range(5)]
136
+ coeffs_rows.append(cos_row)
137
+ coeffs_rows.append(sin_row)
138
+ coeffs = sympy.Matrix(coeffs_rows)
139
+ irreps = ('a', 'e_1', 'e_1', 'e_2', 'e_2')
140
+ return eigs, coeffs, irreps
141
+
142
+ if L == 6:
143
+ # Benzene hexagon. lambda = {2, 1, 1, -1, -1, -2}.
144
+ # Real basis with sqrt(3)/2 absorbed into row scaling so all
145
+ # entries are rational (matches benzene_1e_analytical_overlap.py).
146
+ eigs = (sympy.Integer(2),
147
+ sympy.Integer(1), sympy.Integer(1),
148
+ sympy.Integer(-1), sympy.Integer(-1),
149
+ sympy.Integer(-2))
150
+ coeffs = sympy.Matrix([
151
+ [1, 1, 1, 1, 1, 1], # k=0, a_2u
152
+ [sympy.Rational(2), sympy.Integer(1), sympy.Integer(-1),
153
+ sympy.Integer(-2), sympy.Integer(-1), sympy.Integer(1)], # k=1c
154
+ [sympy.Integer(0), sympy.Integer(1), sympy.Integer(1),
155
+ sympy.Integer(0), sympy.Integer(-1), sympy.Integer(-1)], # k=1s
156
+ [sympy.Rational(2), sympy.Integer(-1), sympy.Integer(-1),
157
+ sympy.Integer(2), sympy.Integer(-1), sympy.Integer(-1)], # k=2c
158
+ [sympy.Integer(0), sympy.Integer(1), sympy.Integer(-1),
159
+ sympy.Integer(0), sympy.Integer(1), sympy.Integer(-1)], # k=2s
160
+ [sympy.Integer(1), sympy.Integer(-1), sympy.Integer(1),
161
+ sympy.Integer(-1), sympy.Integer(1), sympy.Integer(-1)], # k=3
162
+ ])
163
+ irreps = ('a_2u', 'e_1g', 'e_1g', 'e_2u', 'e_2u', 'b_2g')
164
+ return eigs, coeffs, irreps
165
+
166
+ return None # caller falls through to generic path
167
+
168
+
169
+ def _generic_solve(adjacency_matrix, site_labels, h, s):
170
+ """Diagonalize an arbitrary adjacency matrix symbolically via
171
+ sympy.Matrix.eigenvects. Returns (eigenvalues, coefficients) with
172
+ rows = MO eigenvectors, sorted by descending eigenvalue."""
173
+ A = sympy.Matrix(adjacency_matrix)
174
+ if A.rows != A.cols or A.rows != len(site_labels):
175
+ raise ValueError(
176
+ f"adjacency shape {A.shape} inconsistent with "
177
+ f"{len(site_labels)} site labels")
178
+
179
+ raw = A.eigenvects() # list of (eigenvalue, multiplicity, basis)
180
+
181
+ triples = []
182
+ for eig, mult, basis in raw:
183
+ for vec in basis:
184
+ triples.append((sympy.simplify(eig), vec))
185
+
186
+ def _sort_key(eig):
187
+ try:
188
+ return -float(eig)
189
+ except (TypeError, ValueError):
190
+ return -float(sympy.N(eig))
191
+
192
+ triples.sort(key=lambda t: _sort_key(t[0]))
193
+
194
+ eigs = tuple(t[0] for t in triples)
195
+ rows = [list(t[1]) for t in triples]
196
+ rows = [[entry[0] if hasattr(entry, '__iter__') else entry
197
+ for entry in row] for row in rows]
198
+ coeffs = sympy.Matrix(rows)
199
+ return eigs, coeffs
200
+
201
+
202
+ def _check_ghep_precondition(adjacency, overlap, h, s):
203
+ """If user supplies an explicit overlap matrix, verify
204
+ overlap == I + (s/h)*adjacency. Raise HuckelGHEPMismatchError if not."""
205
+ if overlap is None:
206
+ return
207
+ A = sympy.Matrix(adjacency)
208
+ S_user = sympy.Matrix(overlap)
209
+ if S_user.shape != A.shape:
210
+ raise ValueError(f"overlap shape {S_user.shape} != adjacency {A.shape}")
211
+ expected = sympy.eye(A.rows) + (s / h) * A
212
+ diff = sympy.simplify(S_user - expected)
213
+ if diff != sympy.zeros(*A.shape):
214
+ raise HuckelGHEPMismatchError(
215
+ "user-supplied overlap does not satisfy S = I + (s/h)*H. "
216
+ "The MO-direct shortcut requires this form; for richer "
217
+ "overlap structures, solve the full GHEP elsewhere.")
218
+
219
+
220
+ def solve_ring(L, h='h', s='s', basis='real', site_labels=None):
221
+ """Solve the cyclic Huckel ring of L sites.
222
+
223
+ Parameters
224
+ ----------
225
+ L : int
226
+ Ring size.
227
+ h : str | sympy.Symbol
228
+ Resonance integral symbol (default 'h').
229
+ s : str | sympy.Symbol
230
+ AO overlap symbol (default 's').
231
+ basis : 'real' | 'complex'
232
+ Real (cos/sin) Bloch basis (default) or complex exponentials.
233
+ Currently only 'real' is implemented.
234
+ site_labels : optional sequence of L strings
235
+ Defaults to first L lowercase letters ('a', 'b', ..., chr(96+L)).
236
+
237
+ Returns
238
+ -------
239
+ HuckelResult
240
+ """
241
+ if basis != 'real':
242
+ raise NotImplementedError("only basis='real' is implemented in v1")
243
+ h_sym = _resolve_symbol(h, 'h')
244
+ s_sym = _resolve_symbol(s, 's')
245
+ if site_labels is None:
246
+ site_labels = tuple(chr(ord('a') + j) for j in range(L))
247
+ else:
248
+ site_labels = tuple(site_labels)
249
+ if len(site_labels) != L:
250
+ raise ValueError(f"need {L} site_labels, got {len(site_labels)}")
251
+
252
+ special = _ring_special_case(L)
253
+ if special is not None:
254
+ eigs, coeffs, irreps = special
255
+ return HuckelResult(
256
+ site_labels=site_labels,
257
+ eigenvalues=eigs,
258
+ energies=_energies_from_eigenvalues(eigs, h_sym, s_sym),
259
+ coefficients=coeffs,
260
+ h_symbol=h_sym,
261
+ s_symbol=s_sym,
262
+ point_group=f'D_{L}h',
263
+ irrep_labels=irreps,
264
+ )
265
+
266
+ # general L: build adjacency of C_L and fall through
267
+ adjacency = sympy.zeros(L, L)
268
+ for j in range(L):
269
+ adjacency[j, (j + 1) % L] = 1
270
+ adjacency[(j + 1) % L, j] = 1
271
+ eigs, coeffs = _generic_solve(adjacency, site_labels, h_sym, s_sym)
272
+ return HuckelResult(
273
+ site_labels=site_labels,
274
+ eigenvalues=eigs,
275
+ energies=_energies_from_eigenvalues(eigs, h_sym, s_sym),
276
+ coefficients=coeffs,
277
+ h_symbol=h_sym,
278
+ s_symbol=s_sym,
279
+ point_group=f'D_{L}h',
280
+ irrep_labels=None,
281
+ )
282
+
283
+
284
+ def solve(adjacency, site_labels=None, h='h', s='s', overlap=None):
285
+ """Solve a general Huckel graph.
286
+
287
+ Parameters
288
+ ----------
289
+ adjacency : matrix-like
290
+ n x n symmetric adjacency matrix (entries are coefficients of
291
+ h, typically 0/1 or symbolic per-edge weights).
292
+ site_labels : optional sequence of n strings.
293
+ h, s : str | sympy.Symbol
294
+ Resonance and overlap symbols.
295
+ overlap : optional matrix-like
296
+ Explicit overlap matrix. If provided, must equal I + (s/h)*adjacency
297
+ or HuckelGHEPMismatchError is raised. If None, the canonical
298
+ S = I + (s/h)H form is assumed.
299
+
300
+ Returns
301
+ -------
302
+ HuckelResult
303
+ """
304
+ h_sym = _resolve_symbol(h, 'h')
305
+ s_sym = _resolve_symbol(s, 's')
306
+ A = sympy.Matrix(adjacency)
307
+ if site_labels is None:
308
+ site_labels = tuple(chr(ord('a') + j) for j in range(A.rows))
309
+ else:
310
+ site_labels = tuple(site_labels)
311
+
312
+ _check_ghep_precondition(A, overlap, h_sym, s_sym)
313
+
314
+ eigs, coeffs = _generic_solve(A, site_labels, h_sym, s_sym)
315
+ return HuckelResult(
316
+ site_labels=site_labels,
317
+ eigenvalues=eigs,
318
+ energies=_energies_from_eigenvalues(eigs, h_sym, s_sym),
319
+ coefficients=coeffs,
320
+ h_symbol=h_sym,
321
+ s_symbol=s_sym,
322
+ )
symvb/mo_projection.py ADDED
@@ -0,0 +1,252 @@
1
+ """
2
+ MO -> AO Slater-determinant projection.
3
+
4
+ Given a set of occupied MOs (rows of an AO-coefficient matrix) and an
5
+ ordered AO Slater-determinant basis (symvb's native `m.basis`), expand
6
+ the MO determinant in the AO basis via Slater--Laplace cofactor
7
+ expansion. For each AO determinant |T_alpha; T_beta>,
8
+
9
+ amp(T_alpha, T_beta) = (sign_v * sign_ab) * det(C_alpha[:, T_alpha])
10
+ * det(C_beta [:, T_beta])
11
+
12
+ where C_spin is the submatrix of `mo_coeffs` selecting the rows for
13
+ the spin's occupied MOs, and the two sign factors convert between
14
+ symvb's det-string creation order, spin-block ordering, and the
15
+ canonical interleaved (alpha_j, beta_j) ordering used inside the
16
+ cofactor formula.
17
+
18
+ This is the engine that lets a user write down a candidate eigenvector
19
+ of a 400-dim VB problem (or 22, or 9) without diagonalising it: pick
20
+ the desired MO occupation pattern, hand it to this function, and the
21
+ resulting vector is exact-symbolic in whatever field the MO coefficients
22
+ live in.
23
+ """
24
+ from itertools import combinations
25
+
26
+ import numpy
27
+ import sympy
28
+
29
+ from symvb.spin import _symvb_to_canonical_sign
30
+
31
+
32
+ class EigenpairResidualError(AssertionError):
33
+ """Raised by `verify_eigenpair` when (H - E*S)*v has any nonzero
34
+ component after simplification. Carries the index of the first
35
+ offending entry plus its raw and simplified residual expressions."""
36
+
37
+ def __init__(self, index, residual, simplified):
38
+ self.index = index
39
+ self.residual = residual
40
+ self.simplified = simplified
41
+ super().__init__(
42
+ f"residual entry [{index}] is nonzero: {simplified}")
43
+
44
+
45
+ def verify_eigenpair(H, S, v, E, simplify=None):
46
+ """Prove that (H - E*S)*v == 0 as a polynomial identity.
47
+
48
+ Forms the residual r = H*v - E*(S*v), simplifies each component
49
+ with `simplify` (default sympy.cancel; pass sympy.simplify for
50
+ surd cases). Returns True if every component is identically zero;
51
+ otherwise raises EigenpairResidualError on the first offender.
52
+
53
+ Parameters
54
+ ----------
55
+ H, S : sympy.Matrix
56
+ n x n symbolic Hamiltonian and overlap.
57
+ v : sympy.Matrix (n x 1) or convertible
58
+ Candidate eigenvector.
59
+ E : sympy expression
60
+ Candidate eigenvalue (function of the same symbols as H, S).
61
+ simplify : callable, optional
62
+ Per-entry simplifier. Defaults to `sympy.cancel` (best for
63
+ rational-function residuals); use `sympy.simplify` if surds
64
+ are involved.
65
+ """
66
+ if simplify is None:
67
+ simplify = sympy.cancel
68
+ residual = sympy.Matrix(H) * sympy.Matrix(v) \
69
+ - E * (sympy.Matrix(S) * sympy.Matrix(v))
70
+ for i in range(residual.rows):
71
+ raw = residual[i, 0]
72
+ simp = simplify(sympy.together(raw))
73
+ if simp != 0:
74
+ raise EigenpairResidualError(i, raw, simp)
75
+ return True
76
+
77
+
78
+ def _spin_orbital_interleave_sign(T_alpha, T_beta):
79
+ """Parity of the permutation that brings the spin-block ordering
80
+ [a_{i_1}, a_{i_2}, ..., b_{j_1}, b_{j_2}, ...] (all alphas first,
81
+ then all betas, each block sorted by site) into the interleaved
82
+ canonical ordering (alpha_0, beta_0, alpha_1, beta_1, ...).
83
+
84
+ With T_alpha and T_beta sorted, intra-block inversions vanish, so
85
+ only the cross-block term contributes: encoding alpha_j as 2j (even)
86
+ and beta_j as 2j+1 (odd), an inversion across blocks happens iff
87
+ j_alpha > j_beta. This is O(|T_alpha| * |T_beta|) instead of
88
+ O((|T_alpha| + |T_beta|)^2)."""
89
+ inv = sum(1 for j_a in T_alpha for j_b in T_beta if j_a > j_b)
90
+ return 1 if inv % 2 == 0 else -1
91
+
92
+
93
+ def _is_sympy(M):
94
+ return isinstance(M, sympy.MatrixBase)
95
+
96
+
97
+ def _site_index_map(basis_dets, site_labels):
98
+ """Return dict {char -> int} from lowercase site letters to indices.
99
+ If `site_labels` is given, use that order; otherwise infer from the
100
+ lowercase characters of basis_dets in first-appearance order."""
101
+ if site_labels is not None:
102
+ return {c: i for i, c in enumerate(site_labels)}
103
+ seen = []
104
+ for ds in basis_dets:
105
+ for c in ds:
106
+ cl = c.lower()
107
+ if cl not in seen:
108
+ seen.append(cl)
109
+ seen.sort()
110
+ return {c: i for i, c in enumerate(seen)}
111
+
112
+
113
+ def mo_determinant_in_ao(mo_coeffs, occupation, basis_dets,
114
+ site_labels=None):
115
+ """Expand an MO Slater determinant in the symvb AO det basis.
116
+
117
+ Parameters
118
+ ----------
119
+ mo_coeffs : sympy.Matrix or numpy.ndarray, shape (n_mo, n_sites)
120
+ Row k = MO k expanded over the AOs in `site_labels` order.
121
+ Backend (sympy vs numpy) is auto-detected from this argument
122
+ and propagates to the returned vector.
123
+ occupation : (alpha_indices, beta_indices)
124
+ Two iterables of MO row indices (into `mo_coeffs`) listing the
125
+ occupied alpha and beta MOs. For closed-shell, the two are
126
+ equal.
127
+ basis_dets : sequence of str
128
+ AO Slater-det strings in symvb's convention (lowercase = alpha,
129
+ uppercase = beta, creation order = string order). Typically
130
+ `[fp.dets[0].det_string for fp in m.basis]`.
131
+ site_labels : optional sequence of n_sites lowercase chars
132
+ Maps the AO label to a column index of `mo_coeffs`. If None,
133
+ inferred from `basis_dets` (first-appearance, then sorted).
134
+
135
+ Returns
136
+ -------
137
+ Column vector (sympy.Matrix or numpy.ndarray of float) of length
138
+ len(basis_dets), giving the AO-det expansion coefficients of
139
+ |occupation>.
140
+ """
141
+ alpha_indices = list(occupation[0])
142
+ beta_indices = list(occupation[1])
143
+ n_alpha = len(alpha_indices)
144
+ n_beta = len(beta_indices)
145
+
146
+ site_idx = _site_index_map(basis_dets, site_labels)
147
+ n_sites = len(site_idx)
148
+
149
+ use_sympy = _is_sympy(mo_coeffs)
150
+
151
+ # Submatrices: occupied alpha MOs, occupied beta MOs, all AOs (cols).
152
+ if use_sympy:
153
+ C_alpha = mo_coeffs[alpha_indices, :]
154
+ C_beta = mo_coeffs[beta_indices, :]
155
+ else:
156
+ C = numpy.asarray(mo_coeffs)
157
+ C_alpha = C[alpha_indices, :]
158
+ C_beta = C[beta_indices, :]
159
+
160
+ # Memoize cofactor minors over distinct sorted T-tuples. Each T
161
+ # has size n_alpha (resp. n_beta); for benzene closed-shell this
162
+ # collapses 400 evaluations to C(6, 3) = 20 per spin.
163
+ det_alpha_cache = {}
164
+ det_beta_cache = {}
165
+
166
+ def det_alpha(T):
167
+ if T in det_alpha_cache:
168
+ return det_alpha_cache[T]
169
+ if use_sympy:
170
+ d = C_alpha[:, list(T)].det()
171
+ else:
172
+ d = numpy.linalg.det(C_alpha[:, list(T)])
173
+ det_alpha_cache[T] = d
174
+ return d
175
+
176
+ def det_beta(T):
177
+ if T in det_beta_cache:
178
+ return det_beta_cache[T]
179
+ if use_sympy:
180
+ d = C_beta[:, list(T)].det()
181
+ else:
182
+ d = numpy.linalg.det(C_beta[:, list(T)])
183
+ det_beta_cache[T] = d
184
+ return d
185
+
186
+ # Collect amplitudes in a Python list, then build the result vector
187
+ # in one go. This avoids per-element sympy.Matrix.__setitem__ /
188
+ # numpy item-assignment overhead, which dominates the inner loop
189
+ # for the 400-dim benzene case.
190
+ zero = sympy.Integer(0) if use_sympy else 0.0
191
+ amps = [zero] * len(basis_dets)
192
+ for I, ds in enumerate(basis_dets):
193
+ T_a, T_b = [], []
194
+ for c in ds:
195
+ j = site_idx[c.lower()]
196
+ (T_a if c.islower() else T_b).append(j)
197
+ if len(T_a) != n_alpha or len(T_b) != n_beta:
198
+ # AO det doesn't have the right electron count for this
199
+ # occupation; amplitude is zero by orthogonality.
200
+ continue
201
+ T_a_sorted = tuple(sorted(T_a))
202
+ T_b_sorted = tuple(sorted(T_b))
203
+
204
+ sign_ab = _spin_orbital_interleave_sign(T_a_sorted, T_b_sorted)
205
+ sign_v = _symvb_to_canonical_sign(ds, site_idx)
206
+
207
+ d_a = det_alpha(T_a_sorted)
208
+ d_b = det_beta(T_b_sorted)
209
+
210
+ amps[I] = sign_v * sign_ab * d_a * d_b
211
+
212
+ if use_sympy:
213
+ return sympy.Matrix(amps)
214
+ arr = numpy.asarray(amps)
215
+ if numpy.iscomplexobj(arr):
216
+ arr = arr.real
217
+ return arr.astype(float, copy=False)
218
+
219
+
220
+ def linear_combination_in_ao(mo_coeffs, terms, basis_dets, site_labels=None):
221
+ """Project a linear combination of MO Slater determinants into the
222
+ AO det basis.
223
+
224
+ Returns sum_i c_i * mo_determinant_in_ao(mo_coeffs, occ_i, ...) for
225
+ each (c_i, occ_i) pair in `terms`. Backend (sympy vs numpy) is
226
+ inherited from `mo_coeffs`.
227
+
228
+ Parameters
229
+ ----------
230
+ mo_coeffs : sympy.Matrix or numpy.ndarray, shape (n_mo, n_sites)
231
+ Same as in `mo_determinant_in_ao`.
232
+ terms : iterable of (coefficient, occupation) pairs
233
+ Each `occupation` is `(alpha_indices, beta_indices)`. The
234
+ coefficient is multiplied into the corresponding determinant.
235
+ basis_dets, site_labels : same as in `mo_determinant_in_ao`.
236
+
237
+ Returns
238
+ -------
239
+ Column vector (sympy.Matrix or numpy.ndarray) of length
240
+ len(basis_dets), the AO-det expansion of the linear combination.
241
+ """
242
+ terms = list(terms)
243
+ if not terms:
244
+ raise ValueError("terms must be non-empty")
245
+ use_sympy = _is_sympy(mo_coeffs)
246
+ coef0, occ0 = terms[0]
247
+ result = coef0 * mo_determinant_in_ao(
248
+ mo_coeffs, occ0, basis_dets, site_labels=site_labels)
249
+ for coef, occ in terms[1:]:
250
+ result = result + coef * mo_determinant_in_ao(
251
+ mo_coeffs, occ, basis_dets, site_labels=site_labels)
252
+ return result