torchspin 0.3.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.
Files changed (116) hide show
  1. torchspin/__init__.py +363 -0
  2. torchspin/_cardamom_istos.py +327 -0
  3. torchspin/_cardamom_propagatedm.py +413 -0
  4. torchspin/_cardamom_utils.py +113 -0
  5. torchspin/_compile.py +100 -0
  6. torchspin/_linalg.py +140 -0
  7. torchspin/angmom.py +742 -0
  8. torchspin/autograd.py +557 -0
  9. torchspin/autoguess.py +342 -0
  10. torchspin/batch.py +225 -0
  11. torchspin/blochsteady.py +274 -0
  12. torchspin/cardamom.py +552 -0
  13. torchspin/chili.py +932 -0
  14. torchspin/chili_sle.py +1233 -0
  15. torchspin/constants.py +88 -0
  16. torchspin/convspec.py +148 -0
  17. torchspin/ctafft.py +91 -0
  18. torchspin/curry.py +241 -0
  19. torchspin/data/FourierSeriesCoefficients.txt +89 -0
  20. torchspin/data/GaussianCascadeCoefficients.txt +56 -0
  21. torchspin/data/isotopedata.txt +416 -0
  22. torchspin/data/spacegroups.txt +637 -0
  23. torchspin/dataproc.py +361 -0
  24. torchspin/dipbackground.py +69 -0
  25. torchspin/dipkernel.py +102 -0
  26. torchspin/diptensor.py +138 -0
  27. torchspin/endorfrq.py +198 -0
  28. torchspin/endorfrq_perturb.py +302 -0
  29. torchspin/eprload.py +1538 -0
  30. torchspin/eprsave.py +165 -0
  31. torchspin/esfit.py +2427 -0
  32. torchspin/evolve.py +274 -0
  33. torchspin/ewrls.py +124 -0
  34. torchspin/excitation.py +107 -0
  35. torchspin/exciteprofile.py +132 -0
  36. torchspin/experiment.py +323 -0
  37. torchspin/exponfit.py +177 -0
  38. torchspin/fastmotion.py +182 -0
  39. torchspin/fdaxis.py +57 -0
  40. torchspin/fitgui.py +350 -0
  41. torchspin/garlic.py +760 -0
  42. torchspin/ham.py +145 -0
  43. torchspin/ham_cf.py +115 -0
  44. torchspin/ham_ee.py +124 -0
  45. torchspin/ham_ez.py +119 -0
  46. torchspin/ham_ezho.py +403 -0
  47. torchspin/ham_hf.py +122 -0
  48. torchspin/ham_nn.py +108 -0
  49. torchspin/ham_nq.py +100 -0
  50. torchspin/ham_nz.py +136 -0
  51. torchspin/ham_oz.py +115 -0
  52. torchspin/ham_so.py +118 -0
  53. torchspin/ham_zf.py +254 -0
  54. torchspin/hamsymm.py +723 -0
  55. torchspin/initstate.py +158 -0
  56. torchspin/isotopologues.py +296 -0
  57. torchspin/levels.py +189 -0
  58. torchspin/levelsplot.py +363 -0
  59. torchspin/lineshape.py +715 -0
  60. torchspin/lpsvd.py +219 -0
  61. torchspin/makespec.py +87 -0
  62. torchspin/mdhmm.py +573 -0
  63. torchspin/mdload.py +788 -0
  64. torchspin/mdtraj2oripot.py +90 -0
  65. torchspin/ml.py +377 -0
  66. torchspin/mlpsvd.py +297 -0
  67. torchspin/nucdata.py +310 -0
  68. torchspin/nucfrq2d.py +228 -0
  69. torchspin/orca2torchspin.py +1198 -0
  70. torchspin/ordering.py +78 -0
  71. torchspin/oripotentialplot.py +246 -0
  72. torchspin/orisel.py +176 -0
  73. torchspin/pepper.py +2019 -0
  74. torchspin/pepper_autograd.py +613 -0
  75. torchspin/photoselect.py +174 -0
  76. torchspin/plegendre.py +131 -0
  77. torchspin/propint.py +127 -0
  78. torchspin/pulse.py +652 -0
  79. torchspin/py.typed +0 -0
  80. torchspin/rapidscan2spc.py +101 -0
  81. torchspin/resfields.py +337 -0
  82. torchspin/resfields_batch.py +446 -0
  83. torchspin/resfields_eig.py +250 -0
  84. torchspin/resfields_perturb.py +706 -0
  85. torchspin/resfreqs_matrix.py +344 -0
  86. torchspin/resfreqs_perturb.py +299 -0
  87. torchspin/resonator.py +310 -0
  88. torchspin/resonatorprofile.py +85 -0
  89. torchspin/rfmixer.py +147 -0
  90. torchspin/rotations.py +87 -0
  91. torchspin/rotutils.py +880 -0
  92. torchspin/saffron.py +1523 -0
  93. torchspin/saffron_pathways.py +92 -0
  94. torchspin/saffron_peaks.py +580 -0
  95. torchspin/saffron_thyme.py +472 -0
  96. torchspin/salt.py +680 -0
  97. torchspin/sigeq.py +71 -0
  98. torchspin/signalprocessing.py +226 -0
  99. torchspin/sitetransforms.py +254 -0
  100. torchspin/sphgrid.py +470 -0
  101. torchspin/spidyan.py +1508 -0
  102. torchspin/spinladder.py +161 -0
  103. torchspin/spinops.py +265 -0
  104. torchspin/spinsystem.py +1427 -0
  105. torchspin/stackplot.py +167 -0
  106. torchspin/stev.py +283 -0
  107. torchspin/stochtraj_diffusion.py +494 -0
  108. torchspin/stochtraj_jump.py +195 -0
  109. torchspin/strainwidth.py +727 -0
  110. torchspin/transmitter.py +89 -0
  111. torchspin/utils.py +605 -0
  112. torchspin-0.3.0.dist-info/METADATA +267 -0
  113. torchspin-0.3.0.dist-info/RECORD +116 -0
  114. torchspin-0.3.0.dist-info/WHEEL +5 -0
  115. torchspin-0.3.0.dist-info/licenses/LICENSE.md +22 -0
  116. torchspin-0.3.0.dist-info/top_level.txt +1 -0
torchspin/angmom.py ADDED
@@ -0,0 +1,742 @@
1
+ """Angular momentum functions for torchspin.
2
+
3
+ Port of EasySpin's:
4
+ - ``wigner3j.m`` — Wigner 3-j symbol
5
+ - ``wignerd.m`` — Wigner rotation matrix D^J_{m1,m2}
6
+ - ``spherharm.m`` — spherical harmonics Y_L^M(theta, phi)
7
+ - ``clebschgordan.m``— Clebsch-Gordan coefficient
8
+ - ``cgmatrix.m`` — CG transformation matrix (uncoupled→coupled)
9
+
10
+ All functions follow EasySpin's conventions.
11
+
12
+ Notation
13
+ --------
14
+ * Wigner 3-j symbols: ``wigner3j(j1,j2,j3,m1,m2,m3)``
15
+ * Wigner D-matrix: ``wignerd(J, alpha, beta, gamma)``
16
+ - Default phase convention '-': D = exp(-i·m1·α) d(β) exp(-i·m2·γ)
17
+ - Basis ordered m = +J, +J-1, ..., -J (same as EasySpin)
18
+ * Spherical harmonics: ``spherharm(L, M, theta, phi)``
19
+ - theta = polar angle (from +z), phi = azimuthal (from +x)
20
+ - Condon-Shortley phase included for complex harmonics
21
+ * Clebsch-Gordan: ``clebschgordan(j1,j2,j,m1,m2,m)``
22
+ - Related to 3j by: CG = (-1)^(j1-j2+m) * sqrt(2j+1) * W3j(j1,j2,j; m1,m2,-m)
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import cmath
27
+ import math
28
+ from typing import Optional, Union
29
+
30
+ import numpy as np
31
+ import scipy.special as sps
32
+
33
+
34
+ __all__ = [
35
+ 'wigner3j',
36
+ 'wigner6j',
37
+ 'wignerd',
38
+ 'spherharm',
39
+ 'clebschgordan',
40
+ 'cgmatrix',
41
+ 'isto',
42
+ 'isto2stev',
43
+ ]
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Internal helpers
47
+ # ---------------------------------------------------------------------------
48
+
49
+ def _logfact(n: int) -> float:
50
+ """Natural log of n! (n must be a non-negative integer)."""
51
+ return float(sps.gammaln(n + 1))
52
+
53
+
54
+ def _istriangle(a, b, c) -> bool:
55
+ return (a + b >= c) and (b + c >= a) and (c + a >= b)
56
+
57
+
58
+ def _ishalfint(x) -> bool:
59
+ return (2 * x) == int(2 * x)
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # wigner3j
64
+ # ---------------------------------------------------------------------------
65
+
66
+ def wigner3j(j1, j2, j3, m1, m2, m3) -> float:
67
+ """Wigner 3-j symbol.
68
+
69
+ Parameters
70
+ ----------
71
+ j1, j2, j3 : int or half-int
72
+ Angular momentum quantum numbers (non-negative, integer or half-integer).
73
+ m1, m2, m3 : int or half-int
74
+ Magnetic quantum numbers with ``|mi| <= ji``.
75
+
76
+ Returns
77
+ -------
78
+ float
79
+ Value of the Wigner 3-j symbol.
80
+
81
+ Notes
82
+ -----
83
+ Uses the Racah formula with log-factorials for numerical stability
84
+ (accurate for j up to ~100). Returns 0 for any selection-rule violation.
85
+
86
+ Selection rules (return 0 if violated)
87
+ - m1 + m2 + m3 = 0
88
+ - Triangle inequality for (j1, j2, j3)
89
+ - |mi| <= ji
90
+ - 2*(ji + mi) is integer (half-integer or integer quantum numbers only)
91
+ """
92
+ # Coerce to float
93
+ j1, j2, j3 = float(j1), float(j2), float(j3)
94
+ m1, m2, m3 = float(m1), float(m2), float(m3)
95
+
96
+ # Selection rules
97
+ if abs(m1 + m2 + m3) > 1e-10:
98
+ return 0.0
99
+ if not _istriangle(j1, j2, j3):
100
+ return 0.0
101
+ if abs(m1) > j1 + 1e-10 or abs(m2) > j2 + 1e-10 or abs(m3) > j3 + 1e-10:
102
+ return 0.0
103
+ for j, m in ((j1, m1), (j2, m2), (j3, m3)):
104
+ if not _ishalfint(j) or not _ishalfint(m):
105
+ return 0.0
106
+ if not _ishalfint(j + m):
107
+ return 0.0
108
+
109
+ # Triangle coefficient (log scale)
110
+ tri_ln = (
111
+ _logfact(int(round(j1 + j2 - j3))) +
112
+ _logfact(int(round(j1 - j2 + j3))) +
113
+ _logfact(int(round(-j1 + j2 + j3))) -
114
+ _logfact(int(round(j1 + j2 + j3 + 1)))
115
+ )
116
+
117
+ # Pre-factor (log scale)
118
+ pre_ln = (
119
+ _logfact(int(round(j1 + m1))) + _logfact(int(round(j1 - m1))) +
120
+ _logfact(int(round(j2 + m2))) + _logfact(int(round(j2 - m2))) +
121
+ _logfact(int(round(j3 + m3))) + _logfact(int(round(j3 - m3)))
122
+ )
123
+
124
+ prefactor_ln = (tri_ln + pre_ln) / 2.0
125
+
126
+ # Racah summation
127
+ # t runs over values making all factorials non-negative
128
+ tmin = max(0,
129
+ int(round(j2 - m1 - j3)),
130
+ int(round(j1 + m2 - j3)))
131
+ tmax = min(int(round(j1 + j2 - j3)),
132
+ int(round(j1 - m1)),
133
+ int(round(j2 + m2)))
134
+
135
+ if tmin > tmax:
136
+ return 0.0
137
+
138
+ total = 0.0
139
+ for t in range(tmin, tmax + 1):
140
+ sign = (-1) ** t
141
+ term_ln = -(
142
+ _logfact(t) +
143
+ _logfact(int(round(j1 + j2 - j3 - t))) +
144
+ _logfact(int(round(j1 - m1 - t))) +
145
+ _logfact(int(round(j2 + m2 - t))) +
146
+ _logfact(int(round(j3 - j2 + m1 + t))) +
147
+ _logfact(int(round(j3 - j1 - m2 + t)))
148
+ )
149
+ total += sign * math.exp(prefactor_ln + term_ln)
150
+
151
+ overall_sign = (-1) ** int(round(j1 - j2 - m3))
152
+ return overall_sign * total
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # wignerd
157
+ # ---------------------------------------------------------------------------
158
+
159
+ def wignerd(J, alpha_or_beta, beta=None, gamma=None,
160
+ phase: str = '-') -> Union[complex, np.ndarray]:
161
+ """Wigner rotation matrix D^J (or reduced d^J).
162
+
163
+ Parameters
164
+ ----------
165
+ J : float or sequence [J, m1, m2]
166
+ Angular momentum quantum number. If a 3-element sequence [J, m1, m2],
167
+ only the single element D^J_{m1,m2} is returned.
168
+ alpha_or_beta : float
169
+ If ``beta`` is ``None``: the polar angle β only (α=γ=0, returns real d).
170
+ Otherwise: the first Euler angle α.
171
+ beta : float, optional
172
+ Middle Euler angle β.
173
+ gamma : float, optional
174
+ Last Euler angle γ.
175
+ phase : {'-', '+'}, optional
176
+ Sign convention. Default ``'-'``:
177
+ ``D = exp(-i·m1·α) · d(β) · exp(-i·m2·γ)`` (Brink/Satchler, Varshalovich).
178
+ ``'+'``: ``D = exp(+i·m1·α) · d(β) · exp(+i·m2·γ)`` (Edmonds).
179
+
180
+ Returns
181
+ -------
182
+ ndarray, shape (2J+1, 2J+1) or complex scalar
183
+ Full matrix (or single element). Basis ordered m = +J, ..., -J.
184
+ When called with β only (α=γ=0), returns a real ndarray.
185
+
186
+ Notes
187
+ -----
188
+ The reduced Wigner d-matrix element is computed via the Wigner sum formula
189
+ using log-factorials (exact for integer and half-integer J).
190
+ """
191
+ # Parse first argument: scalar J or [J, m1, m2]
192
+ J_arg = np.asarray(J, dtype=float).ravel()
193
+ if J_arg.size == 3:
194
+ Jval, m1_req, m2_req = J_arg
195
+ single_element = True
196
+ elif J_arg.size == 1:
197
+ Jval = float(J_arg[0])
198
+ single_element = False
199
+ m1_req = m2_req = None
200
+ else:
201
+ raise ValueError("First argument must be J (scalar) or [J, m1, m2].")
202
+
203
+ beta_only = beta is None
204
+ if beta_only:
205
+ alpha_val = 0.0
206
+ beta_val = float(alpha_or_beta)
207
+ gamma_val = 0.0
208
+ else:
209
+ alpha_val = float(alpha_or_beta)
210
+ beta_val = float(beta)
211
+ gamma_val = float(gamma)
212
+
213
+ if phase not in ('+', '-'):
214
+ raise ValueError("phase must be '+' or '-'.")
215
+ sign = -1.0 if phase == '-' else +1.0
216
+
217
+ # Basis: m = +J, J-1, ..., -J
218
+ nJ = int(round(2 * Jval)) + 1
219
+ ms = np.arange(Jval, -Jval - 0.5, -1) # +J ... -J
220
+
221
+ def _d_element(m1: float, m2: float) -> float:
222
+ """Reduced Wigner d^J_{m1,m2}(beta_val)."""
223
+ cb2 = math.cos(beta_val / 2)
224
+ sb2 = math.sin(beta_val / 2)
225
+
226
+ tmin = max(0, int(round(m2 - m1)))
227
+ tmax = min(int(round(Jval + m2)), int(round(Jval - m1)))
228
+
229
+ if tmin > tmax:
230
+ return 0.0
231
+
232
+ # Pre-factor ln: sqrt[(J+m1)!(J-m1)!(J+m2)!(J-m2)!]
233
+ pre_ln = 0.5 * (
234
+ _logfact(int(round(Jval + m1))) +
235
+ _logfact(int(round(Jval - m1))) +
236
+ _logfact(int(round(Jval + m2))) +
237
+ _logfact(int(round(Jval - m2)))
238
+ )
239
+
240
+ total = 0.0
241
+ for t in range(tmin, tmax + 1):
242
+ exp_cos = int(round(2 * Jval - 2 * t + m2 - m1))
243
+ exp_sin = int(round(2 * t + m1 - m2))
244
+
245
+ if exp_cos < 0 or exp_sin < 0:
246
+ continue
247
+
248
+ # Avoid log(0) for sin/cos at 0 or pi
249
+ if cb2 == 0.0 and exp_cos > 0:
250
+ continue
251
+ if sb2 == 0.0 and exp_sin > 0:
252
+ continue
253
+
254
+ # Compute log|term|
255
+ denom_ln = (
256
+ _logfact(int(round(Jval - m1 - t))) +
257
+ _logfact(t) +
258
+ _logfact(int(round(t + m1 - m2))) +
259
+ _logfact(int(round(Jval + m2 - t)))
260
+ )
261
+ ln_abs = pre_ln - denom_ln
262
+ if exp_cos > 0:
263
+ ln_abs += exp_cos * math.log(abs(cb2))
264
+ if exp_sin > 0:
265
+ ln_abs += exp_sin * math.log(abs(sb2))
266
+
267
+ # Signs: (-1)^(m2-m1+t) from cos^a * sin^b convention
268
+ sign_t = (-1) ** int(round(m2 - m1 + t))
269
+ # Account for negative values of cb2, sb2 (sin always ≥ 0 for beta in [0,pi])
270
+ if cb2 < 0 and exp_cos % 2 == 1:
271
+ sign_t *= -1
272
+ if sb2 < 0 and exp_sin % 2 == 1:
273
+ sign_t *= -1
274
+
275
+ total += sign_t * math.exp(ln_abs)
276
+
277
+ return total
278
+
279
+ if single_element:
280
+ m1v = float(m1_req); m2v = float(m2_req)
281
+ d_val = _d_element(m1v, m2v)
282
+ if beta_only:
283
+ return d_val
284
+ # Apply Euler angles with phase sign
285
+ return _cexp(sign * 1j * m1v * alpha_val) * d_val * _cexp(sign * 1j * m2v * gamma_val)
286
+
287
+ # Build full matrix
288
+ D = np.zeros((nJ, nJ), dtype=complex if not beta_only else float)
289
+ for i, m1v in enumerate(ms):
290
+ for j, m2v in enumerate(ms):
291
+ d_val = _d_element(m1v, m2v)
292
+ if beta_only:
293
+ D[i, j] = d_val
294
+ else:
295
+ D[i, j] = (_cexp(sign * 1j * m1v * alpha_val) *
296
+ d_val *
297
+ _cexp(sign * 1j * m2v * gamma_val))
298
+
299
+ return D
300
+
301
+
302
+ def _cexp(x: complex) -> complex:
303
+ """Complex exponential exp(x) for pure-imaginary x = i*theta."""
304
+ return cmath.exp(x)
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # spherharm
309
+ # ---------------------------------------------------------------------------
310
+
311
+ def spherharm(L: int, M: int, theta, phi, kind: str = 'c') -> np.ndarray:
312
+ """Spherical harmonic Y_L^M(theta, phi).
313
+
314
+ Parameters
315
+ ----------
316
+ L : int
317
+ Degree (non-negative integer).
318
+ M : int
319
+ Order (``-L <= M <= L``).
320
+ theta : float or array_like
321
+ Polar angle (colatitude from +z), radians.
322
+ phi : float or array_like
323
+ Azimuthal angle (longitude from +x), radians.
324
+ kind : {'c', 'r'}, optional
325
+ ``'c'`` (default): complex spherical harmonics with Condon-Shortley phase.
326
+ ``'r'``: real spherical harmonics.
327
+
328
+ Returns
329
+ -------
330
+ ndarray or complex
331
+ Spherical harmonic value(s). Shape matches ``theta`` / ``phi``.
332
+
333
+ Notes
334
+ -----
335
+ Uses ``scipy.special.sph_harm`` for complex harmonics.
336
+ Argument order follows EasySpin: ``spherharm(L, M, theta, phi)``
337
+ (theta = polar, phi = azimuthal), which is the reverse of scipy's
338
+ ``sph_harm(M, L, phi, theta)``.
339
+
340
+ Real harmonics follow Blanco et al. 1997, Table 1 (non-negative near θ=0, φ=0).
341
+ """
342
+ L, M = int(L), int(M)
343
+ theta = np.asarray(theta, dtype=float)
344
+ phi = np.asarray(phi, dtype=float)
345
+ if theta.shape != phi.shape:
346
+ raise ValueError("theta and phi must have the same shape.")
347
+
348
+ if kind == 'c':
349
+ # scipy.special.sph_harm_y(n, m, theta, phi) — EasySpin arg order matches
350
+ return sps.sph_harm_y(L, M, theta, phi)
351
+ elif kind == 'r':
352
+ # Real spherical harmonics (Condon-Shortley-free, non-negative near 0)
353
+ absM = abs(M)
354
+ # Normalization
355
+ p = math.factorial(L - absM) / math.factorial(L + absM)
356
+ N = math.sqrt((2 * L + 1) / (4 * math.pi) * p)
357
+ P = sps.lpmv(absM, L, np.cos(theta))
358
+ if M > 0:
359
+ return math.sqrt(2) * N * P * np.cos(M * phi)
360
+ elif M < 0:
361
+ return math.sqrt(2) * N * P * np.sin(absM * phi)
362
+ else:
363
+ return N * P
364
+ else:
365
+ raise ValueError("kind must be 'c' (complex) or 'r' (real).")
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # clebschgordan
370
+ # ---------------------------------------------------------------------------
371
+
372
+ def clebschgordan(j1, j2, j, m1, m2, m) -> float:
373
+ """Clebsch-Gordan coefficient ``<j1,m1; j2,m2 | j,m>``.
374
+
375
+ Parameters
376
+ ----------
377
+ j1, j2 : float
378
+ Angular momenta of the two subsystems.
379
+ j : float
380
+ Total angular momentum.
381
+ m1, m2, m : float
382
+ Magnetic quantum numbers.
383
+
384
+ Returns
385
+ -------
386
+ float
387
+ Clebsch-Gordan coefficient.
388
+
389
+ Notes
390
+ -----
391
+ Related to the Wigner 3-j symbol by:
392
+ ``CG = (-1)^(j1-j2+m) * sqrt(2j+1) * W3j(j1,j2,j; m1,m2,-m)``
393
+ (Brink & Satchler, Angular Momentum, 3rd ed., eq. 3.3).
394
+ """
395
+ j1, j2, j = float(j1), float(j2), float(j)
396
+ m1, m2, m = float(m1), float(m2), float(m)
397
+ return ((-1) ** int(round(j1 - j2 + m)) *
398
+ math.sqrt(2 * j + 1) *
399
+ wigner3j(j1, j2, j, m1, m2, -m))
400
+
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # cgmatrix
404
+ # ---------------------------------------------------------------------------
405
+
406
+ def cgmatrix(S1: float, S2: float, Stot=None) -> tuple:
407
+ """Transformation matrix between uncoupled and coupled spin representations.
408
+
409
+ Parameters
410
+ ----------
411
+ S1, S2 : float
412
+ Spin quantum numbers (1/2, 1, 3/2, 2, …).
413
+ Stot : float or None, optional
414
+ If given, return only coupled states with total spin ``Stot``.
415
+
416
+ Returns
417
+ -------
418
+ U2C : ndarray, shape (n, n)
419
+ Transformation matrix with elements ``<Stot,mStot|mS1,mS2>``.
420
+ Apply as: ``psi_c = U2C @ psi_u`` (uncoupled→coupled).
421
+ Smtot : ndarray, shape (n, 2)
422
+ ``[Stot, mStot]`` for each coupled basis state.
423
+ m12 : ndarray, shape (n, 2)
424
+ ``[mS1, mS2]`` for each uncoupled basis state.
425
+
426
+ Notes
427
+ -----
428
+ Basis ordering matches EasySpin:
429
+
430
+ * Uncoupled: descending mS1 (outer), descending mS2 (inner).
431
+ * Coupled: descending Stot (outer), descending mStot (inner).
432
+
433
+ Uses :func:`clebschgordan` to compute each matrix element.
434
+ """
435
+ S1, S2 = float(S1), float(S2)
436
+
437
+ if Stot is None:
438
+ Stot_list = list(np.arange(S1 + S2, abs(S1 - S2) - 0.5, -1))
439
+ else:
440
+ Stot = float(Stot)
441
+ if Stot > S1 + S2 or Stot < abs(S1 - S2):
442
+ raise ValueError(f"Stot={Stot} is outside the valid range "
443
+ f"[{abs(S1-S2)}, {S1+S2}].")
444
+ Stot_list = [Stot]
445
+
446
+ nCoupled = int(sum(2 * St + 1 for St in Stot_list))
447
+ nUncoupled = int(round((2 * S1 + 1) * (2 * S2 + 1)))
448
+ U2C = np.zeros((nCoupled, nUncoupled))
449
+ Smtot = np.zeros((nCoupled, 2))
450
+ m12 = np.zeros((nUncoupled, 2))
451
+
452
+ # Build uncoupled index → (mS1, mS2) mapping
453
+ iu = 0
454
+ for mS1 in np.arange(S1, -S1 - 0.5, -1):
455
+ for mS2 in np.arange(S2, -S2 - 0.5, -1):
456
+ m12[iu] = [mS1, mS2]
457
+ ic = 0
458
+ for St in Stot_list:
459
+ for mSt in np.arange(St, -St - 0.5, -1):
460
+ U2C[ic, iu] = clebschgordan(S1, S2, St, mS1, mS2, mSt)
461
+ if iu == 0:
462
+ Smtot[ic] = [St, mSt]
463
+ ic += 1
464
+ iu += 1
465
+
466
+ return U2C, Smtot, m12
467
+
468
+
469
+ # ---------------------------------------------------------------------------
470
+ # wigner6j
471
+ # ---------------------------------------------------------------------------
472
+
473
+ def wigner6j(j1, j2, j3, J1, J2, J3) -> float:
474
+ """Wigner 6-j symbol.
475
+
476
+ Computes::
477
+
478
+ / j1 j2 j3 \\
479
+ \\ J1 J2 J3 /
480
+
481
+ Parameters
482
+ ----------
483
+ j1, j2, j3, J1, J2, J3 : int or half-int
484
+ Angular momentum quantum numbers.
485
+
486
+ Returns
487
+ -------
488
+ float
489
+ Value of the Wigner 6-j symbol (0 if any triangle rule is violated).
490
+
491
+ Notes
492
+ -----
493
+ Uses the Tuzun–Burkhardt–Secrest algorithm (CPC 112, 1998, Formula 80,
494
+ Method 2 — recursive binomial expansion). Accurate for typical EPR
495
+ angular momentum values.
496
+
497
+ Selection rules (return 0 if violated)
498
+ - Triangle inequality for (j1,j2,j3), (j1,J2,J3), (J1,j2,J3), (J1,J2,j3)
499
+ - All six arguments must be non-negative integers or half-integers
500
+ """
501
+ j1, j2, j3 = float(j1), float(j2), float(j3)
502
+ J1, J2, J3 = float(J1), float(J2), float(J3)
503
+
504
+ # All must be half-integers (2*j is integer)
505
+ for v in (j1, j2, j3, J1, J2, J3):
506
+ if abs(round(2 * v) - 2 * v) > 1e-10 or v < 0:
507
+ return 0.0
508
+
509
+ # Four triangle inequalities
510
+ if not (_istriangle(j1, j2, j3) and _istriangle(j1, J2, J3) and
511
+ _istriangle(J1, j2, J3) and _istriangle(J1, J2, j3)):
512
+ return 0.0
513
+
514
+ def _fac(n: int) -> float:
515
+ """Factorial as float."""
516
+ return float(math.factorial(int(round(n))))
517
+
518
+ def _bino(a: float, b: float) -> float:
519
+ """Binomial coefficient C(a, b) = a! / (b! * (a-b)!)."""
520
+ ai, bi = int(round(a)), int(round(b))
521
+ if bi < 0 or ai < bi:
522
+ return 0.0
523
+ v = 1.0
524
+ for i in range(bi):
525
+ v = v * (ai - i) / (i + 1)
526
+ return v
527
+
528
+ # Tuzun 1998 Formula (80) Method 2
529
+ alpha = sorted([j1 + j2 + J1 + J2,
530
+ j1 + j3 + J1 + J3,
531
+ j2 + j3 + J2 + J3])
532
+ A1, A2, A3 = alpha
533
+
534
+ beta = sorted([j1 + j2 + j3,
535
+ j1 + J2 + J3,
536
+ J1 + j2 + J3,
537
+ J1 + J2 + j3])
538
+ B1, B2, B3, B4 = beta
539
+
540
+ # Prefactor (square root form)
541
+ pre = math.sqrt(
542
+ _fac(A1 - B2) * _fac(A1 - B3) * _fac(A1 - B4) / _fac(A1 - B1) / _fac(B1 + 1) *
543
+ _fac(A2 - B1) * _fac(A2 - B3) * _fac(A2 - B4) / _fac(A2 - B2) / _fac(B2 + 1) *
544
+ _fac(A3 - B1) * _fac(A3 - B2) * _fac(A3 - B4) / _fac(A3 - B3) / _fac(B3 + 1) *
545
+ _fac(B4 + 1)
546
+ )
547
+ sumfactor = ((-1) ** int(round(B4)) *
548
+ _bino(A1 - B1, A1 - B4) *
549
+ _bino(A2 - B2, A2 - B4) *
550
+ _bino(A3 - B3, A3 - B4))
551
+ pre = pre * sumfactor
552
+
553
+ if B4 >= A1 - 1e-10:
554
+ return pre
555
+
556
+ # Recursive sum from n = B4+1 to A1
557
+ t = 1.0
558
+ s = t
559
+ for n in range(int(round(B4)) + 1, int(round(A1)) + 1):
560
+ t = -t * (n + 1) / (n - B4) * (A1 - n + 1) / (n - B1) * \
561
+ (A2 - n + 1) / (n - B2) * (A3 - n + 1) / (n - B3)
562
+ s += t
563
+ return pre * s
564
+
565
+
566
+ # ---------------------------------------------------------------------------
567
+ # isto (Irreducible Spherical Tensor Operators)
568
+ # ---------------------------------------------------------------------------
569
+
570
+ def isto(J, kq, q_arg=None) -> np.ndarray:
571
+ """Irreducible spherical tensor operator T^k_q.
572
+
573
+ Port of EasySpin's ``isto.m`` (Ryabov 1999, J. Magn. Reson. 140, 141).
574
+
575
+ Parameters
576
+ ----------
577
+ J : float or list of float
578
+ Angular momentum quantum number(s). A single float for one spin,
579
+ a list for a multi-spin product operator.
580
+ kq : array_like or int
581
+ If ``q_arg`` is ``None``:
582
+ - Shape (nSpins, 2): each row ``[k, q]`` for the corresponding spin.
583
+ - Shape (nTerms, 3): each row ``[k, q, spin_index]`` (1-based).
584
+ If ``q_arg`` is given, ``kq`` is the rank ``k`` (single-spin shorthand).
585
+ q_arg : int, optional
586
+ When provided, ``kq`` is ``k`` and ``q_arg`` is ``q`` (single-spin call).
587
+
588
+ Returns
589
+ -------
590
+ ndarray, complex128
591
+ The (dim × dim) irreducible spherical tensor operator matrix, where
592
+ dim = product of (2*Ji + 1) for all spins.
593
+
594
+ Examples
595
+ --------
596
+ >>> T = isto(0.5, 1, 0) # T^1_0 for S=1/2 (= Sz / sqrt(2), unnorm)
597
+ >>> T = isto(1.0, [2, 0]) # T^2_0 for S=1
598
+ """
599
+ # --- Normalise input ---
600
+ if q_arg is not None:
601
+ # isto(J, k, q) shorthand
602
+ k_single = int(round(float(kq)))
603
+ q_single = int(round(float(q_arg)))
604
+ kq_arr = np.array([[k_single, q_single]], dtype=float)
605
+ J_list = [float(J)]
606
+ else:
607
+ kq_arr = np.atleast_2d(np.asarray(kq, dtype=float))
608
+ if np.isscalar(J) or (hasattr(J, '__len__') and len(J) == 1):
609
+ J_list = [float(J) if np.isscalar(J) else float(J[0])]
610
+ else:
611
+ J_list = [float(j) for j in J]
612
+
613
+ # If kq has 3 columns, it uses [k, q, spin_index] format
614
+ if kq_arr.shape[1] == 3:
615
+ nSpins = len(J_list)
616
+ kq_full = np.zeros((nSpins, 2))
617
+ for row in kq_arr:
618
+ i = int(round(row[2])) - 1 # 1-based → 0-based
619
+ kq_full[i] = row[:2]
620
+ kq_arr = kq_full
621
+ elif kq_arr.shape[0] != len(J_list) and len(J_list) == 1:
622
+ # Single spin, kq given as 1×2 or (2,)
623
+ pass # already fine
624
+ elif kq_arr.shape[0] != len(J_list):
625
+ raise ValueError("kq must have one row per spin in J.")
626
+
627
+ # --- Build product operator via Kronecker products ---
628
+ T = np.array([[1.0 + 0j]]) # 1×1 identity (will be kron-expanded)
629
+ for idx, Ji in enumerate(J_list):
630
+ k_i = int(round(kq_arr[idx, 0]))
631
+ q_i = int(round(kq_arr[idx, 1]))
632
+ Tj = _isto_single(Ji, k_i, q_i)
633
+ T = np.kron(T, Tj)
634
+ return T
635
+
636
+
637
+ def _isto_single(J: float, k: int, q: int) -> np.ndarray:
638
+ """ISTO T^k_q for a single spin J (Ryabov 1999).
639
+
640
+ Parameters
641
+ ----------
642
+ J : float spin quantum number (0, 1/2, 1, ...)
643
+ k : int rank (0, 1, 2, ...)
644
+ q : int projection (−k ≤ q ≤ k)
645
+ """
646
+ if abs(q) > k:
647
+ raise ValueError(f"q={q} must satisfy |q| <= k={k}.")
648
+ if k < 0:
649
+ raise ValueError("k must be a non-negative integer.")
650
+
651
+ dim = int(round(2 * J + 1))
652
+ if J == 0:
653
+ return np.ones((1, 1), dtype=complex)
654
+
655
+ # Build J+ and J- raising/lowering operators
656
+ # basis: m = +J, J-1, ..., -J (descending)
657
+ from torchspin.spinops import sop
658
+ Jp = sop(J, '+').numpy().astype(complex)
659
+ Jm = sop(J, '-').numpy().astype(complex)
660
+
661
+ # T^k_k = N_kk * (J+)^k (Ryabov Eq.[2] + [17])
662
+ Nkk = ((-1.0) ** k) / (2.0 ** (k / 2.0))
663
+ T = Nkk * np.linalg.matrix_power(Jp, k)
664
+
665
+ # Lower projection from k down to |q| via Racah commutation (Ryabov Eq.[1])
666
+ for q_ in range(k, abs(q), -1):
667
+ denom = math.sqrt(k * (k + 1) - q_ * (q_ - 1))
668
+ T = (Jm @ T - T @ Jm) / denom
669
+
670
+ # Negative q via Hermitian conjugate (Ryabov Eq.[3])
671
+ if q < 0:
672
+ T = ((-1.0) ** q) * T.conj().T
673
+
674
+ return T
675
+
676
+
677
+ # ---------------------------------------------------------------------------
678
+ # isto2stev (ISTO ↔ Stevens operator transformation matrix)
679
+ # ---------------------------------------------------------------------------
680
+
681
+ def isto2stev(k: int) -> np.ndarray:
682
+ """Transformation matrix from ISTOs to Stevens operators.
683
+
684
+ Port of EasySpin's ``isto2stev.m``.
685
+
686
+ Computes the (2k+1) × (2k+1) matrix **C** such that::
687
+
688
+ O_k = C @ T_k
689
+
690
+ where T_k = [T^k_k, T^k_{k-1}, ..., T^k_{-k}] (ISTO column vector)
691
+ and O_k = [O^k_k, O^k_{k-1}, ..., O^k_{-k}] (Stevens operator vector).
692
+
693
+ Parameters
694
+ ----------
695
+ k : int
696
+ Rank (non-negative integer).
697
+
698
+ Returns
699
+ -------
700
+ C : ndarray, shape (2k+1, 2k+1), complex128
701
+ Transformation matrix. Inverse is ``numpy.linalg.inv(C)``.
702
+
703
+ Notes
704
+ -----
705
+ Only diagonal and anti-diagonal elements are non-zero.
706
+ Uses ``J = k/2`` (smallest spin for which rank-k operators exist).
707
+ The result is independent of the choice of J.
708
+
709
+ Examples
710
+ --------
711
+ >>> C = isto2stev(2) # 5×5 matrix for rank-2 operators
712
+ >>> C.shape
713
+ (5, 5)
714
+ """
715
+ if not isinstance(k, int) or k < 0:
716
+ raise ValueError("k must be a non-negative integer.")
717
+
718
+ from torchspin.stev import stev as _stev
719
+
720
+ nq = 2 * k + 1
721
+ # Use smallest usable spin: J = k/2
722
+ # (stev and isto require 2J >= k)
723
+ J = k / 2.0
724
+
725
+ # Build ISTO and Stevens operator vectors (ordering: q = k, k-1, ..., -k)
726
+ T_list = []
727
+ O_list = []
728
+ for q in range(k, -k - 1, -1):
729
+ T_list.append(_isto_single(J, k, q))
730
+ O_arr = _stev(J, k, q).numpy().astype(complex)
731
+ O_list.append(O_arr)
732
+
733
+ C = np.zeros((nq, nq), dtype=complex)
734
+ for t in range(nq):
735
+ traceTT = np.trace(T_list[t].conj().T @ T_list[t])
736
+ if abs(traceTT) < 1e-14:
737
+ continue
738
+ # Only diagonal (o=t) and anti-diagonal (o=nq-1-t) are non-zero
739
+ for o in {t, nq - 1 - t}:
740
+ C[o, t] = np.trace(O_list[o].T @ T_list[t]) / traceTT
741
+
742
+ return C