Skip to content

Commit 33e5554

Browse files
committed
feat: Allow custom callable potentials in exchange kernel functions
1 parent d67a131 commit 33e5554

6 files changed

Lines changed: 115 additions & 32 deletions

File tree

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ The package performs calculations in dimensionless units where lengths are scale
3737
- **Coulomb interaction**: The code assumes a potential of the form $V(q) = \kappa \frac{2\pi e^2}{q \ell_B}$ (in effective dimensionless form).
3838
- If you set `kappa = 1.0`, the resulting exchange kernels are in units of the Coulomb energy scale $E_C = e^2 / (\epsilon \ell_B)$.
3939
- To express results in units of the cyclotron energy $\hbar \omega_c$, set $\kappa = E_C / (\hbar \omega_c) = (e^2/\epsilon \ell_B) / (\hbar \omega_c)$.
40-
- **General potential**: For a general $V(q)$, `V_of_q` should return values in your desired energy units. The integration measure $d^2q/(2\pi)^2$ introduces a factor of $1/\ell_B^2$, so ensure your potential scaling is consistent.
40+
- **Custom potential**: Provide a callable `potential(q)` that returns values in your desired energy units. The integration measure $d^2q/(2\pi)^2$ introduces a factor of $1/\ell_B^2$, so ensure your potential scaling is consistent.
4141

4242
## Installation
4343

@@ -74,7 +74,7 @@ print("F shape:", F.shape)
7474
print("X shape:", X.shape)
7575
```
7676

77-
To use a user-provided Coulomb interaction, pass a callable `V_of_q` and select the `"general"` potential:
77+
To use a user-provided interaction, pass a callable directly as `potential`:
7878

7979
```python
8080
def V_coulomb(q, kappa=1.0):
@@ -86,8 +86,7 @@ X_coulomb = get_exchange_kernels(
8686
thetas,
8787
nmax,
8888
method="gausslegendre",
89-
potential="general",
90-
V_of_q=lambda q: V_coulomb(q, kappa=1.0),
89+
potential=lambda q: V_coulomb(q, kappa=1.0),
9190
)
9291
```
9392

src/quantumhall_matrixelements/exchange_gausslag.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,8 @@ def get_exchange_kernels_GaussLag(
6161
G_angles,
6262
nmax: int,
6363
*,
64-
potential: str = "coulomb",
64+
potential: str | callable = "coulomb",
6565
kappa: float = 1.0,
66-
V_of_q=None,
6766
nquad: int = 200,
6867
ell: float = 1.0,
6968
) -> "ComplexArray":
@@ -76,13 +75,11 @@ def get_exchange_kernels_GaussLag(
7675
nmax :
7776
Number of Landau levels.
7877
potential :
79-
Either ``'coulomb'`` (default) or ``'general'``. In the latter case
80-
a callable ``V_of_q(q)`` must be provided.
78+
``'coulomb'`` (default), ``'constant'``, or a callable ``V(q)`` giving
79+
the interaction in 1/ℓ units.
8180
kappa :
8281
Interaction strength prefactor. For Coulomb this corresponds to
8382
:math:`\\kappa = e^2/(\\varepsilon\\ell_B)/\\hbar\\omega_c`.
84-
V_of_q :
85-
Callable ``V_of_q(q) -> V(q)`` used when ``potential='general'``.
8683
nquad :
8784
Number of Gauss–Laguerre quadrature points.
8885
ell :
@@ -99,6 +96,21 @@ def get_exchange_kernels_GaussLag(
9996
raise ValueError("G_magnitudes and G_angles must have the same shape.")
10097
nG = G_magnitudes.size
10198

99+
# Resolve potential
100+
if callable(potential):
101+
pot_kind = "callable"
102+
pot_fn = potential
103+
else:
104+
pot_kind = str(potential).strip().lower()
105+
pot_fn = None
106+
107+
if pot_kind in {"coulomb", "constant"}:
108+
pass
109+
elif pot_kind == "callable":
110+
pass
111+
else:
112+
raise ValueError("potential must be 'coulomb', 'constant', or a callable V(q).")
113+
102114
Gscaled = G_magnitudes * float(ell)
103115
Xs = np.zeros((nG, nmax, nmax, nmax, nmax), dtype=np.complex128)
104116

@@ -131,16 +143,12 @@ def get_exchange_kernels_GaussLag(
131143
phase_factor = (1j) ** (d1 - d2)
132144
pref = (kappa * C / np.sqrt(2.0)) * phase_factor
133145
else:
134-
if not callable(V_of_q):
135-
raise ValueError(
136-
"For potential='general', provide V_of_q: callable(q)->V(q)."
137-
)
138146
alpha = 0.5 * (d1 + d2)
139147
z, w = _lag_nodes_weights(nquad, alpha)
140148
L1 = _laguerre_on_grid(p, d1, alpha, nquad, z)
141149
L2 = _laguerre_on_grid(q, d2, alpha, nquad, z)
142150
qvals = np.sqrt(2.0 * z) / float(ell)
143-
Veff = V_of_q(qvals) / (2.0 * np.pi * float(ell) ** 2)
151+
Veff = pot_fn(qvals) / (2.0 * np.pi * float(ell) ** 2)
144152
W = w * L1 * L2 * Veff
145153
key = (absN, float(alpha))
146154
J_abs = J_cache.get(key)
@@ -160,4 +168,3 @@ def get_exchange_kernels_GaussLag(
160168

161169

162170
__all__ = ["get_exchange_kernels_GaussLag"]
163-

src/quantumhall_matrixelements/exchange_hankel.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,28 @@ def get_exchange_kernels_hankel(
7575
G_magnitudes: "RealArray",
7676
G_angles: "RealArray",
7777
nmax: int,
78-
**kwargs,
78+
*,
79+
potential: str | callable = "coulomb",
80+
kappa: float = 1.0,
7981
) -> "ComplexArray":
8082
"""Compute X_{n1,m1,n2,m2}(G) via Hankel transforms (κ=1 convention).
8183
8284
This backend parametrizes the radial integral via Hankel transforms with
8385
robust Laguerre-based normalization and explicit control over the Bessel
8486
order. It is numerically more intensive than the Gauss–Laguerre backend
8587
but can be useful for cross-checks or alternative potentials.
88+
89+
Parameters
90+
----------
91+
G_magnitudes, G_angles :
92+
Arrays describing |G| and polar angle θ_G (same shape, no broadcasting).
93+
nmax :
94+
Number of Landau levels.
95+
potential :
96+
``'coulomb'`` (default), ``'constant'``, or a callable ``V(q)`` giving
97+
the interaction in 1/ℓ units.
98+
kappa :
99+
Prefactor for Coulomb/constant cases.
86100
"""
87101
G_magnitudes = np.asarray(G_magnitudes, dtype=float)
88102
G_angles = np.asarray(G_angles, dtype=float)
@@ -94,6 +108,18 @@ def get_exchange_kernels_hankel(
94108
k_unique, inv_idx = np.unique(G_magnitudes, return_inverse=True)
95109
nG = G_magnitudes.size
96110

111+
# Resolve potential
112+
if callable(potential):
113+
pot_callable = potential
114+
pot_key = ("callable", id(potential))
115+
else:
116+
pot_name = str(potential).strip().lower()
117+
if pot_name in {"coulomb", "constant"}:
118+
pot_callable = pot_name
119+
pot_key = (pot_name, float(kappa))
120+
else:
121+
raise ValueError("potential must be 'coulomb', 'constant', or callable V(q)")
122+
97123
# Precompute angular phase vectors for all possible N values
98124
# N = (n1 - m1) - (m2 - n2) ∈ [Nmin, Nmax]
99125
N_min = -2 * (nmax - 1)
@@ -115,8 +141,8 @@ def get_exchange_kernels_hankel(
115141
np.arange(nmax)[None, :].repeat(nmax, axis=0),
116142
)
117143

118-
# Cache for radial Hankel transforms keyed by (d1,N1,d2,N2,absN)
119-
radial_cache: dict[tuple[int, int, int, int, int], np.ndarray] = {}
144+
# Cache for radial Hankel transforms keyed by (d1,N1,d2,N2,absN,pot_key)
145+
radial_cache: dict[tuple[int, int, int, int, int, tuple], np.ndarray] = {}
120146

121147
# Output
122148
Xs = np.zeros((nG, nmax, nmax, nmax, nmax), dtype=np.complex128)
@@ -127,13 +153,15 @@ def get_radial_block(n1: int, m1: int, n2: int, m2: int, absN: int) -> np.ndarra
127153
d2 = int(d_mat[n2, m2])
128154
N1 = int(Nmin_mat[n1, m1])
129155
N2 = int(Nmin_mat[n2, m2])
130-
key = (d1, N1, d2, N2, int(absN))
156+
key = (d1, N1, d2, N2, int(absN), pot_key)
131157
arr = radial_cache.get(key)
132158
if arr is not None:
133159
return arr
134160

135161
def integrand(q):
136-
return _radial_exchange_integrand_rgamma(q, n1, m1, n2, m2)
162+
return _radial_exchange_integrand_rgamma(
163+
q, n1, m1, n2, m2, potential=pot_callable, kappa=kappa
164+
)
137165

138166
ht = _get_hankel_transformer(absN)
139167
# Compute only on unique radii, then cache
@@ -162,4 +190,3 @@ def integrand(q):
162190

163191

164192
__all__ = ["get_exchange_kernels_hankel"]
165-

src/quantumhall_matrixelements/exchange_legendre.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,8 @@ def get_exchange_kernels_GaussLegendre(
5757
G_angles,
5858
nmax: int,
5959
*,
60-
potential: str = "coulomb",
60+
potential: str | callable = "coulomb",
6161
kappa: float = 1.0,
62-
V_of_q=None,
6362
nquad: int = 1000,
6463
scale: float = 0.5,
6564
ell: float = 1.0,
@@ -78,11 +77,9 @@ def get_exchange_kernels_GaussLegendre(
7877
nmax :
7978
Number of Landau levels.
8079
potential :
81-
Either ``'coulomb'`` (default) or ``'general'``.
80+
``'coulomb'`` (default) or a callable ``V(q)`` returning the interaction.
8281
kappa :
8382
Interaction strength prefactor.
84-
V_of_q :
85-
Callable ``V_of_q(q) -> V(q)`` used when ``potential='general'``.
8683
nquad :
8784
Number of quadrature points (default 1000).
8885
scale :
@@ -104,6 +101,21 @@ def get_exchange_kernels_GaussLegendre(
104101
Gscaled = G_magnitudes * float(ell)
105102
Xs = np.zeros((nG, nmax, nmax, nmax, nmax), dtype=np.complex128)
106103

104+
# Resolve potential
105+
if callable(potential):
106+
pot_kind = "callable"
107+
pot_fn = potential
108+
else:
109+
pot_kind = str(potential).strip().lower()
110+
pot_fn = None
111+
112+
if pot_kind == "coulomb":
113+
pass
114+
elif pot_kind == "callable":
115+
pass
116+
else:
117+
raise ValueError("potential must be 'coulomb' or a callable V(q).")
118+
107119
# Get mapped grid
108120
z, w = _legendre_nodes_weights_mapped(nquad, scale)
109121

@@ -154,16 +166,13 @@ def get_exchange_kernels_GaussLegendre(
154166
pref = (kappa * C / np.sqrt(2.0)) * phase_factor
155167

156168
else:
157-
# General potential
158-
if not callable(V_of_q):
159-
raise ValueError("Provide V_of_q for potential='general'")
160-
169+
# General/callable potential
161170
alpha = 0.5 * (d1 + d2)
162171
L1 = sps.eval_genlaguerre(p, d1, z)
163172
L2 = sps.eval_genlaguerre(q, d2, z)
164173

165174
qvals = np.sqrt(2.0 * z) / float(ell)
166-
Veff = V_of_q(qvals) / (2.0 * np.pi * float(ell) ** 2)
175+
Veff = pot_fn(qvals) / (2.0 * np.pi * float(ell) ** 2)
167176

168177
if absN not in J_cache:
169178
arg = np.sqrt(2.0 * z)[None, :] * Gscaled[:, None]

tests/test_exchange_legendre.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,24 @@ def test_legendre_large_n_stability():
3030
# This should not raise an error
3131
X = get_exchange_kernels_GaussLegendre(Gs_dimless, thetas, nmax, nquad=500)
3232
assert np.isfinite(X).all()
33+
34+
35+
def test_legendre_callable_potential_matches_coulomb():
36+
"""Callable potential should reproduce Coulomb when given V(q)=2πκ/q."""
37+
nmax = 3
38+
Gs_dimless = np.array([0.3, 1.1])
39+
thetas = np.array([0.0, 0.7])
40+
kappa = 1.2
41+
nquad = 300
42+
43+
def V_coulomb(q):
44+
return kappa * 2.0 * np.pi / q
45+
46+
X_coulomb = get_exchange_kernels_GaussLegendre(
47+
Gs_dimless, thetas, nmax, potential="coulomb", kappa=kappa, nquad=nquad
48+
)
49+
X_callable = get_exchange_kernels_GaussLegendre(
50+
Gs_dimless, thetas, nmax, potential=V_coulomb, nquad=nquad
51+
)
52+
53+
assert np.allclose(X_callable, X_coulomb, rtol=1e-4, atol=1e-4)

tests/test_validation.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,23 @@ def test_gausslag_convergence():
9393

9494
assert np.allclose(X_low, X_high, rtol=1e-6, atol=1e-4), \
9595
"Gauss-Laguerre quadrature not converged between nquad=50 and nquad=200"
96+
97+
98+
def test_hankel_callable_potential_matches_coulomb():
99+
"""Hankel backend should respect a user-supplied callable identical to Coulomb."""
100+
nmax = 2
101+
Gs_dimless = np.array([0.8, 1.6])
102+
thetas = np.array([0.0, 0.4])
103+
kappa = 0.9
104+
105+
def V_coulomb(q):
106+
return kappa * 2.0 * np.pi / q
107+
108+
X_coulomb = get_exchange_kernels(
109+
Gs_dimless, thetas, nmax, method="hankel", potential="coulomb", kappa=kappa
110+
)
111+
X_callable = get_exchange_kernels(
112+
Gs_dimless, thetas, nmax, method="hankel", potential=V_coulomb
113+
)
114+
115+
assert np.allclose(X_callable, X_coulomb, rtol=1e-4, atol=1e-4)

0 commit comments

Comments
 (0)