Repository: qutip/qutip

Issue:
High Clebsch-Gordan coefficients give incorrect results because of numeric instability
The current version of `clebsch` gives wildly incorrect results for high `j`, because it relies on the precise cancellation of extremely large quantities. This leads to incorrect answers for anything that depends on this function, such as the spin Wigner function for Rydberg atoms.

Example of problem:

```
clebsch(15, 15, 30, 0, 0, 0) 
> 7.03237620084722 
clebsch(20, 20, 40, 0, 0, 0)
> 8986860.153685257
clebsch(30, 30, 60, 0, 0, 0)
> nan
```

Is it possible to implement one of the many higher-precision methods to compute Clebsch-Gordan coefficients? If that would be slower, is it possible to add a flag to toggle between the methods?


Discussion:

Resolving patch:
diff --git a/qutip/utilities.py b/qutip/utilities.py
--- a/qutip/utilities.py
+++ b/qutip/utilities.py
@@ -111,6 +111,21 @@ def linspace_with(start, stop, num=50, elems=[]):
     return np.union1d(lspace, elems)
 
 
+def _factorial_prod(N, arr):
+    arr[:N] += 1
+
+
+def _factorial_div(N, arr):
+    arr[:N] -= 1
+
+
+def _to_long(arr):
+    prod = 1
+    for i, v in enumerate(arr):
+        prod *= (i+1)**int(v)
+    return prod
+
+
 def clebsch(j1, j2, j3, m1, m2, m3):
     """Calculates the Clebsch-Gordon coefficient
     for coupling (j1,m1) and (j2,m2) to give (j3,m3).
@@ -141,27 +156,39 @@ def clebsch(j1, j2, j3, m1, m2, m3):
         Requested Clebsch-Gordan coefficient.
 
     """
-    from scipy.special import factorial
-
     if m3 != m1 + m2:
         return 0
     vmin = int(np.max([-j1 + j2 + m3, -j1 + m1, 0]))
     vmax = int(np.min([j2 + j3 + m1, j3 - j1 + j2, j3 + m3]))
 
-    C = np.sqrt((2.0 * j3 + 1.0) * factorial(j3 + j1 - j2) *
-                factorial(j3 - j1 + j2) * factorial(j1 + j2 - j3) *
-                factorial(j3 + m3) * factorial(j3 - m3) /
-                (factorial(j1 + j2 + j3 + 1) *
-                factorial(j1 - m1) * factorial(j1 + m1) *
-                factorial(j2 - m2) * factorial(j2 + m2)))
-    S = 0
-    for v in range(vmin, vmax + 1):
-        S += (-1.0) ** (v + j2 + m2) / factorial(v) * \
-            factorial(j2 + j3 + m1 - v) * factorial(j1 - m1 + v) / \
-            factorial(j3 - j1 + j2 - v) / factorial(j3 + m3 - v) / \
-            factorial(v + j1 - j2 - m3)
-    C = C * S
-    return C
+    c_factor = np.zeros((j1 + j2 + j3 + 1), np.int32)
+    _factorial_prod(j3 + j1 - j2, c_factor)
+    _factorial_prod(j3 - j1 + j2, c_factor)
+    _factorial_prod(j1 + j2 - j3, c_factor)
+    _factorial_prod(j3 + m3, c_factor)
+    _factorial_prod(j3 - m3, c_factor)
+    _factorial_div(j1 + j2 + j3 + 1, c_factor)
+    _factorial_div(j1 - m1, c_factor)
+    _factorial_div(j1 + m1, c_factor)
+    _factorial_div(j2 - m2, c_factor)
+    _factorial_div(j2 + m2, c_factor)
+    C = np.sqrt((2.0 * j3 + 1.0)*_to_long(c_factor))
+
+    s_factors = np.zeros(((vmax + 1 - vmin), (j1 + j2 + j3)), np.int32)
+    sign = (-1) ** (vmin + j2 + m2)
+    for i,v in enumerate(range(vmin, vmax + 1)):
+        factor = s_factors[i,:]
+        _factorial_prod(j2 + j3 + m1 - v, factor)
+        _factorial_prod(j1 - m1 + v, factor)
+        _factorial_div(j3 - j1 + j2 - v, factor)
+        _factorial_div(j3 + m3 - v, factor)
+        _factorial_div(v + j1 - j2 - m3, factor)
+        _factorial_div(v, factor)
+    common_denominator = -np.min(s_factors, axis=0)
+    numerators = s_factors + common_denominator
+    S = sum([(-1)**i * _to_long(vec) for i,vec in enumerate(numerators)]) * \
+        sign / _to_long(common_denominator)
+    return C * S
 
 
 # -----------------------------------------------------------------------------


Test patch:
diff --git a/qutip/tests/test_utilities.py b/qutip/tests/test_utilities.py
--- a/qutip/tests/test_utilities.py
+++ b/qutip/tests/test_utilities.py
@@ -34,7 +34,7 @@
 import numpy as np
 from numpy.testing import assert_, run_module_suite
 
-from qutip import convert_unit
+from qutip import convert_unit, clebsch
 
 
 def test_unit_conversions():
@@ -69,6 +69,49 @@ def test_unit_conversions():
                         orig="meV", to="GHz") - w
     assert_(abs(diff) < 1e-6)
 
+def test_unit_clebsch():
+    "utilities: Clebsch–Gordan coefficients "
+    N = 15
+    for _ in range(100):
+        "sum_m1 sum_m2 C(j1,j2,j3,m1,m2,m3)*C(j1,j2,j3',m1,m2,m3') ="
+        "delta j3,j3' delta m3,m3'"
+        j1 = np.random.randint(0, N+1)
+        j2 = np.random.randint(0, N+1)
+        j3 = np.random.randint(abs(j1-j2), j1+j2+1)
+        j3p = np.random.randint(abs(j1-j2), j1+j2+1)
+        m3 = np.random.randint(-j3, j3+1)
+        m3p = np.random.randint(-j3p, j3p+1)
+        sum_match = -1
+        sum_differ = -int(j3 == j3p and m3 == m3p)
+        for m1 in range(-j1,j1+1):
+            for m2 in range(-j2,j2+1):
+                c1 = clebsch(j1, j2, j3, m1, m2, m3)
+                c2 = clebsch(j1, j2, j3p, m1, m2, m3p)
+                sum_match += c1**2
+                sum_differ += c1*c2
+        assert_(abs(sum_match) < 1e-6)
+        assert_(abs(sum_differ) < 1e-6)
+
+    for _ in range(100):
+        "sum_j3 sum_m3 C(j1,j2,j3,m1,m2,m3)*C(j1,j2,j3,m1',m2',m3) ="
+        "delta m1,m1' delta m2,m2'"
+        j1 = np.random.randint(0,N+1)
+        j2 = np.random.randint(0,N+1)
+        m1 = np.random.randint(-j1,j1+1)
+        m1p = np.random.randint(-j1,j1+1)
+        m2 = np.random.randint(-j2,j2+1)
+        m2p = np.random.randint(-j2,j2+1)
+        sum_match = -1
+        sum_differ = -int(m1 == m1p and m2 == m2p)
+        for j3 in range(abs(j1-j2),j1+j2+1):
+            for m3 in range(-j3,j3+1):
+                c1 = clebsch(j1, j2, j3, m1, m2, m3)
+                c2 = clebsch(j1, j2, j3, m1p, m2p, m3)
+                sum_match += c1**2
+                sum_differ += c1*c2
+        assert_(abs(sum_match) < 1e-6)
+        assert_(abs(sum_differ) < 1e-6)
+
 
 if __name__ == "__main__":
     run_module_suite()