jaxht 0.1.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.
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: jaxht
3
+ Version: 0.1.0
4
+ Summary: JAX-native spherical harmonic transforms (BK-regime first): GPU-capable, differentiable, dependency-controlled.
5
+ Project-URL: Homepage, https://github.com/jrcheshire/jht
6
+ Project-URL: Repository, https://github.com/jrcheshire/jht
7
+ Project-URL: Issues, https://github.com/jrcheshire/jht/issues
8
+ Project-URL: Changelog, https://github.com/jrcheshire/jht/blob/main/CHANGELOG.md
9
+ Author-email: James Cheshire <cheshire@caltech.edu>
10
+ Maintainer-email: James Cheshire <cheshire@caltech.edu>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: automatic-differentiation,cosmic-microwave-background,cosmology,gpu,healpix,jax,nufft,spherical-harmonic-transform
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
23
+ Classifier: Topic :: Scientific/Engineering :: Physics
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.11
26
+ Requires-Dist: jax>=0.4.30
27
+ Requires-Dist: numpy>=1.24
28
+ Provides-Extra: dev
29
+ Requires-Dist: mypy; extra == 'dev'
30
+ Requires-Dist: pytest; extra == 'dev'
31
+ Requires-Dist: ruff; extra == 'dev'
32
+ Provides-Extra: validation
33
+ Requires-Dist: ducc0; extra == 'validation'
34
+ Requires-Dist: healpy; extra == 'validation'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # jht — JAX Harmonic Transforms
38
+
39
+ JAX-native spherical harmonic transforms (map ↔ aₗₘ): **GPU-capable**, **fully
40
+ differentiable**, and **dependency-controlled** (pure JAX + numpy at runtime — no
41
+ compiled C++ extension, no heavyweight third-party SHT library). Scoped to the
42
+ BICEP/Keck regime — **spin-0 and spin-2** on the **HEALPix RING** pixelization,
43
+ ℓ_max ≲ 1000, nside ≤ ~2048 — but written cleanly so it can serve as a general
44
+ transform dependency.
45
+
46
+ It exists to serve the GPU / differentiable tier of analysis that a CPU-only C++
47
+ transform (ducc0) structurally cannot, while *owning the numerics*. See
48
+ [`docs/motivation.md`](docs/motivation.md) for the full decision record.
49
+
50
+ ## Status (2026-06-10)
51
+
52
+ Phases 0–4 **complete and validated** (190 tests pass + 8 GPU-gated skips,
53
+ CPU/float64):
54
+
55
+ - **On-grid transforms** — spin-0 & spin-2 synthesis (`aₗₘ→map`) and the exact
56
+ adjoint `Sᵀ`, validated to machine precision vs healpy **and** ducc0; spin-2
57
+ inverse at the HEALPix floor with **no s2fft-style structural defect**.
58
+ - **Accuracy** — jht's own ring quadrature weights + Jacobi iteration reach
59
+ ~1e-13 on band-limited maps (matches `healpy.map2alm(use_weights=True)`); see
60
+ [`docs/accuracy.md`](docs/accuracy.md).
61
+ - **Partial-sky** — masked pseudo-aₗₘ, a cut-sky CG deconvolution, and a masked
62
+ Wiener filter / constrained realization (the MUSE inner solve); see
63
+ [`docs/masked.md`](docs/masked.md).
64
+ - **Off-grid (NUFFT)** — `synthesis_general` / `adjoint_synthesis_general`
65
+ evaluate a band-limited field at **arbitrary pointings** (spin 0–3), alm- **and**
66
+ pointing-differentiable. The JAX-native replacement for ducc0's
67
+ `sht.experimental.synthesis_general` (on-grid SHT + this NUFFT = the full ducc0
68
+ surface bk-jax depends on); see [`docs/offgrid.md`](docs/offgrid.md).
69
+ - **Differentiability** — native JAX autodiff (`jacfwd ≡ jacrev`, tight adjoint
70
+ identity), plus a convention-clean real-DOF layer `jht.diff`; see
71
+ [`docs/design.md`](docs/design.md) §Differentiability.
72
+ - **GPU** — pure JAX, so the transforms run on CUDA with no code change. Measured on
73
+ Cannon A100/V100 (fp64): GPU==CPU parity ~1e-13 across the BK regime **including
74
+ nside=2048**, forward synthesis 14–60× the CPU. See **Performance** below and
75
+ [`docs/gpu.md`](docs/gpu.md).
76
+
77
+ ## Install
78
+
79
+ Standard env is [pixi](https://pixi.sh):
80
+
81
+ ```bash
82
+ pixi install # CPU env (osx-arm64 / linux-64)
83
+ pixi run test # the gated suite
84
+ ```
85
+
86
+ GPU (CUDA, linux-64 — see [`docs/gpu.md`](docs/gpu.md)):
87
+
88
+ ```bash
89
+ pixi run -e gpu python scripts/gpu_check.py # on an NVIDIA box
90
+ ```
91
+
92
+ As a dependency in another project (runtime deps are just `jax` + `numpy`):
93
+
94
+ ```bash
95
+ pip install jaxht # once released on PyPI — then `import jht`
96
+ # or track the repo directly:
97
+ pip install "jaxht @ git+https://github.com/jrcheshire/jht.git"
98
+ ```
99
+
100
+ ## Quick start
101
+
102
+ float64 is **opt-in per entry point** — enable it *before creating any array*
103
+ (library code never touches the global config):
104
+
105
+ ```python
106
+ import jax
107
+ jax.config.update("jax_enable_x64", True)
108
+ import jax.numpy as jnp
109
+ import jht
110
+
111
+ nside, lmax, spin = 256, 512, 0
112
+ m = jht.synthesis(alm, nside, lmax, spin=spin) # aₗₘ -> map
113
+ a = jht.map2alm(m, nside, lmax, spin=spin, niter=3) # map -> aₗₘ (weighted + iterated)
114
+ cl = jht.bandpower(a, lmax, spin=spin) # angular auto-power C_ℓ
115
+ ```
116
+
117
+ `spin=2` takes/returns `(E, B)` aₗₘ of shape `(2, …)` and `(Q, U)` maps of shape
118
+ `(2, npix)`. `jht.adjoint_synthesis` is the **exact unweighted transpose** `Sᵀ`
119
+ (the operator seam / VJP), distinct from `map2alm` (the approximate inverse). For
120
+ gradient-based work use the real-DOF layer `jht.synthesis_real` /
121
+ `jht.analysis_real` (plain ℝⁿ→ℝᵐ, no complex-conjugate convention subtlety).
122
+
123
+ ## Conventions
124
+
125
+ healpy m-major triangular aₗₘ packing, orthonormal Yₗₘ with the Condon–Shortley
126
+ phase, HEALPix-internal (COSMO) polarization — verified against healpy 1.19.0 and
127
+ ducc0 0.41.0. Pinned in [`docs/design.md`](docs/design.md).
128
+
129
+ ## Accuracy tiers (the contract)
130
+
131
+ jht targets the **GPU/differentiable tier** where the HEALPix ~1e-3 sampling
132
+ floor is acceptable; weights + iteration close it to ~1e-13 on band-limited
133
+ inputs. It is **not** a drop-in for ducc's purity-critical (~1e-4 E→B-leakage)
134
+ production path. Tolerances are a-priori and gate-driven, never relaxed without
135
+ sign-off. Residual mismatches are logged in
136
+ [`DISCREPANCIES.md`](DISCREPANCIES.md).
137
+
138
+ ## Performance
139
+
140
+ Pure JAX runs unchanged on CUDA. Measured on Cannon A100 (incl. a 20 GB MIG) / V100, fp64:
141
+
142
+ - **GPU==CPU parity ~1e-13** across the BK regime, **including nside=2048** (synthesis and `map2alm`).
143
+ - **Forward synthesis 14–60×** the 8-core CPU; fp64/fp32 ≈ 2.2×.
144
+ - **Off-grid forward** ~0.5–0.9 s at ℓ_max=1000 (independent of the number of points; recursion-bound), with the pointing gradient ~1× a forward.
145
+ - **nside=2048** compiles and runs on GPU — a ~20 GB slice holds synthesis + `map2alm`; the one-time compile is multi-minute (jit-cached).
146
+
147
+ The recurring GPU lesson: fp64/complex scatters are catastrophic on GPU, so jht packs and assembles via **gathers**. CPU perf model + memory in [`docs/performance.md`](docs/performance.md); GPU detail in [`docs/gpu.md`](docs/gpu.md).
148
+
149
+ ## Using jht as a dependency
150
+
151
+ jht is standalone and consumer-agnostic. The operator/grad seam a downstream
152
+ needs (e.g. to use jht *in place of ducc0*) — and the accuracy boundary — are
153
+ documented in [`docs/consumers.md`](docs/consumers.md). Any backend-selection
154
+ wiring lives in the consumer, not here.
155
+
156
+ ## Docs
157
+
158
+ - [`docs/design.md`](docs/design.md) — technical design, conventions, the crux, differentiability.
159
+ - [`docs/accuracy.md`](docs/accuracy.md) — the accuracy contract + ring-weight algorithm.
160
+ - [`docs/masked.md`](docs/masked.md) — partial-sky estimators.
161
+ - [`docs/performance.md`](docs/performance.md) — CPU perf model + memory.
162
+ - [`docs/gpu.md`](docs/gpu.md) — the GPU env, the x64 requirement, the harness.
163
+ - [`docs/consumers.md`](docs/consumers.md) — the downstream-dependency seam.
164
+ - [`docs/motivation.md`](docs/motivation.md) — why jht exists.
165
+ - [`ROADMAP.md`](ROADMAP.md) — phased plan + gates.
@@ -0,0 +1,15 @@
1
+ jht/__init__.py,sha256=SyhUjXRnAQWP8-H0ASXw2UGWwaizlIwh6UYq2IWFMHA,3128
2
+ jht/_nufft.py,sha256=rHz7023H2mMMMuKtERFVUUrxT_bCf4Oou-J9hRVq5oI,7078
3
+ jht/_recursion.py,sha256=MjPnWrafuR2Ru3nt4Gb45-hdqAaRhgfi_UuqVXfo_Aw,24951
4
+ jht/_reference.py,sha256=ZrWTDSKQgrqzN1id0WjsvLmrjx4TcvQzqjYq_ykVf88,5459
5
+ jht/analysis.py,sha256=BS4M-GwUNvUjyRLUdAtFxT93fAmSej3-MnqcjgSgOis,2291
6
+ jht/diff.py,sha256=UNBw-j4lR7npGiG1grMmZSocTu_pm4ryIFgrjQABNaY,4130
7
+ jht/healpix.py,sha256=uZrV5cXNgSpwU8r65UwmnveJC8E9mDfO8iGKJ_6FoHs,14837
8
+ jht/masked.py,sha256=DBG-gqz_uwg1kIuRt2Dm8eF9Xsx13E-sJPXc4tav4KY,17320
9
+ jht/offgrid.py,sha256=3t2kesjOtH4Cm2R0Hf47FtmxnKh1VJZoUQdWe9ANENM,9375
10
+ jht/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ jht/weights.py,sha256=WzY2ucCm_Ty6c3VODIwYOy27j6umMZGUlpUZ3pZw9Oc,4613
12
+ jaxht-0.1.0.dist-info/METADATA,sha256=DTyXdwtqi6i3-llXZLorkpSxibtwXJhaielN9qcgk7Y,7855
13
+ jaxht-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
14
+ jaxht-0.1.0.dist-info/licenses/LICENSE,sha256=N8ykVEnf_WjCA9Ymw5GPMitqZrmMx911pDxpl-xBHq4,1071
15
+ jaxht-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 James Cheshire
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
jht/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ """jht -- JAX-native spherical harmonic transforms.
2
+
3
+ A clean-room, pure-JAX implementation of the forward and inverse spherical
4
+ harmonic transform (map <-> a_lm) for **spin-0 and spin-2** fields on the
5
+ **HEALPix RING** pixelization, in the BICEP/Keck angular regime
6
+ (l_max <~ 1000, nside <= ~2048). GPU-capable, fully differentiable under JAX's
7
+ native autodiff, and dependency-controlled (runtime deps = jax + numpy only).
8
+
9
+ Conventions (verified vs healpy 1.19.0 / ducc0 0.41.0; see ``docs/design.md``):
10
+ healpy m-major triangular a_lm packing, orthonormal Y_lm with the
11
+ Condon-Shortley phase, HEALPix-internal (COSMO) polarization.
12
+
13
+ float64 is **opt-in per entry point** -- library code never touches the global
14
+ config. Enable it (before creating any array, on CPU or GPU) with::
15
+
16
+ import jax
17
+ jax.config.update("jax_enable_x64", True)
18
+
19
+ Quick start::
20
+
21
+ import jax; jax.config.update("jax_enable_x64", True)
22
+ import jht
23
+ m = jht.synthesis(alm, nside, lmax, spin=0) # a_lm -> map
24
+ a = jht.map2alm(m, nside, lmax, spin=0, niter=3) # map -> a_lm (weighted)
25
+
26
+ See ``README.md`` for the tour, ``docs/consumers.md`` for the downstream seam
27
+ (e.g. using jht as a ducc0 replacement), and ``docs/gpu.md`` for the GPU story.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from .analysis import bare_analysis, map2alm
33
+ from .diff import analysis_real, bandpower, synthesis_real
34
+ from .healpix import (
35
+ adjoint_synthesis,
36
+ alm_metric_weight,
37
+ alm_size,
38
+ synthesis,
39
+ )
40
+ from .masked import (
41
+ alm_to_real,
42
+ constrained_realization,
43
+ deconvolve,
44
+ n_dof,
45
+ pseudo_alm,
46
+ real_to_alm,
47
+ wiener,
48
+ )
49
+ from .offgrid import adjoint_synthesis_general, synthesis_general
50
+ from .weights import pixel_weights, ring_weights
51
+
52
+ __version__ = "0.1.0" # single source of truth; pyproject reads this via hatch
53
+ __author__ = "James Cheshire"
54
+ __email__ = "cheshire@caltech.edu"
55
+
56
+ __all__ = [
57
+ "__version__",
58
+ # core on-grid transforms
59
+ "synthesis", # S: a_lm -> map
60
+ "adjoint_synthesis", # S^T: map -> a_lm (exact unweighted transpose / VJP)
61
+ # off-grid (arbitrary points / NUFFT) -- spin 0-3, alm- AND pointing-differentiable
62
+ "synthesis_general", # a_lm -> field at arbitrary (theta, phi)
63
+ "adjoint_synthesis_general", # its exact transpose
64
+ # analysis (approximate inverse)
65
+ "bare_analysis", # A0 = S^T W
66
+ "map2alm", # A0 + Jacobi iteration
67
+ # quadrature weights
68
+ "ring_weights",
69
+ "pixel_weights",
70
+ # partial-sky / masked
71
+ "pseudo_alm", # masked pseudo-a_lm (zero-fill)
72
+ "deconvolve", # cut-sky CG deconvolution
73
+ "wiener", # Wiener filter / MUSE inner solve (Cl prior + N^-1)
74
+ "constrained_realization", # posterior draw (constrained realization)
75
+ # differentiable (real-DOF) interface
76
+ "synthesis_real", # S o T^-1 : R^n -> map
77
+ "analysis_real", # T o map2alm : map -> R^n
78
+ "bandpower", # angular auto-power C_ell
79
+ # real-DOF isometry T
80
+ "alm_to_real",
81
+ "real_to_alm",
82
+ "n_dof",
83
+ # a_lm layout / inner-product helpers
84
+ "alm_size",
85
+ "alm_metric_weight", # the (2 - delta_m0) metric G (the bk-jax 2*conj bridge)
86
+ ]
jht/_nufft.py ADDED
@@ -0,0 +1,171 @@
1
+ """Pure-JAX 2D NUFFT (ES kernel) -- the off-grid engine for ``jht.offgrid``.
2
+
3
+ Type-2 (uniform Fourier modes -> nonuniform points) and its exact transpose,
4
+ type-1. Both are differentiable in the nonuniform point coordinates under JAX's
5
+ native autodiff: the integer window index is frozen with ``stop_gradient`` (it is
6
+ genuinely locally constant), so the gradient flows analytically through the
7
+ smooth ES-kernel weights -- no custom rule, ``jacfwd == jacrev`` preserved.
8
+
9
+ The kernel is the FINUFFT "exponential of semicircle"
10
+ ``psi(t) = exp(beta (sqrt(1 - (2t/W)^2) - 1))`` (Barnett, Magland & af Klinteberg
11
+ 2019, arXiv:1808.06736). The accuracy-vs-(sigma, W) trade was pinned empirically
12
+ in the M0 spike (abs err vs the exact direct sum, lmax<=512): W=14/sigma=2 reaches
13
+ <=6e-10; W=12 ~5e-9; W=16 sits at the fp64 roundoff floor.
14
+
15
+ Convention: modes are *centered*, ``k = -N//2 .. -N//2 + N - 1``; points
16
+ ``x, y`` live on the ``[0, 2pi)`` torus. ``nufft2d2`` evaluates
17
+ ``f_j = sum_{k,m} c[k,m] exp(i (k x_j + m y_j))``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from functools import lru_cache
23
+ from typing import NamedTuple
24
+
25
+ import jax
26
+ import jax.numpy as jnp
27
+ import numpy as np
28
+
29
+ TWO_PI = 2.0 * np.pi
30
+
31
+ # requested epsilon -> (W, sigma). Keyed by *achievable* accuracy; the cheapest
32
+ # (smallest W) entry meeting the request is selected. Tuned in scripts/offgrid_spike.py.
33
+ _KERNEL_DB: dict[float, tuple[int, float]] = {
34
+ 1e-4: (6, 2.0),
35
+ 1e-6: (8, 2.0),
36
+ 1e-8: (10, 2.0),
37
+ 1e-10: (14, 2.0),
38
+ 1e-12: (16, 2.0),
39
+ }
40
+
41
+
42
+ def _eps_to_w_sigma(eps: float) -> tuple[int, float]:
43
+ # Keys are achievable accuracies; pick the loosest (cheapest -> largest key)
44
+ # that still meets the request. If eps is tighter than the DB's best, fall
45
+ # back to min(key) -- the smallest key is the most-accurate (largest-W) kernel.
46
+ ok = [e for e in _KERNEL_DB if e <= eps * 1.0000001]
47
+ return _KERNEL_DB[max(ok)] if ok else _KERNEL_DB[min(_KERNEL_DB)]
48
+
49
+
50
+ def _next_size(n: float) -> int:
51
+ """Next even FFT-friendly size (a 2,3,5-smooth integer) >= n."""
52
+ n = int(np.ceil(n))
53
+ if n % 2:
54
+ n += 1
55
+ while True:
56
+ m = n
57
+ for p in (2, 3, 5):
58
+ while m % p == 0:
59
+ m //= p
60
+ if m == 1:
61
+ return n
62
+ n += 2
63
+
64
+
65
+ def _beta(W: int, sigma: float) -> float:
66
+ return float(np.pi * W * (1.0 - 0.5 / sigma))
67
+
68
+
69
+ def _es_kernel(t, beta: float, W: int):
70
+ """ES window ``exp(beta(sqrt(1-(2t/W)^2)-1))``; 0 outside ``|t| <= W/2``."""
71
+ z2 = (2.0 * t / W) ** 2
72
+ val = jnp.exp(beta * (jnp.sqrt(jnp.clip(1.0 - z2, 0.0, 1.0)) - 1.0))
73
+ return jnp.where(z2 <= 1.0, val, 0.0)
74
+
75
+
76
+ def _correction(N: int, n: int, W: int, beta: float) -> np.ndarray:
77
+ """Continuous-FT deconvolution factor ``K^(k)`` for centered modes (real, even)."""
78
+ ks = np.arange(-(N // 2), -(N // 2) + N)
79
+ t, wq = np.polynomial.legendre.leggauss(2 * W + 24)
80
+ t, wq = t * (W / 2.0), wq * (W / 2.0)
81
+ psi = np.exp(beta * (np.sqrt(np.clip(1.0 - (2.0 * t / W) ** 2, 0.0, 1.0)) - 1.0))
82
+ u = TWO_PI / n
83
+ return (psi[None, :] * np.cos(ks[:, None] * u * t[None, :]) * wq[None, :]).sum(1)
84
+
85
+
86
+ class NufftPlan(NamedTuple):
87
+ """Static (host-side) tables for a 2D NUFFT at a given (Nk, Nm, eps)."""
88
+
89
+ Nk: int
90
+ Nm: int
91
+ nk: int # oversampled grid size (theta/k axis)
92
+ nm: int # oversampled grid size (phi/m axis)
93
+ W: int
94
+ beta_k: float
95
+ beta_m: float
96
+ corr_k: np.ndarray # (Nk,)
97
+ corr_m: np.ndarray # (Nm,)
98
+ kbins: np.ndarray # (Nk,) centered modes mod nk
99
+ mbins: np.ndarray # (Nm,) centered modes mod nm
100
+ fill_src: np.ndarray # (nk*nm,) gather index: oversampled cell -> source mode in c.ravel()
101
+ fill_mask: np.ndarray # (nk*nm,) True where a centered mode lands on this cell
102
+
103
+
104
+ @lru_cache(maxsize=None)
105
+ def nufft_plan(Nk: int, Nm: int, eps: float) -> NufftPlan:
106
+ W, sigma = _eps_to_w_sigma(eps)
107
+ nk, nm = _next_size(sigma * Nk), _next_size(sigma * Nm)
108
+ bk, bm = _beta(W, sigma), _beta(W, sigma)
109
+ kbins = np.arange(-(Nk // 2), -(Nk // 2) + Nk) % nk
110
+ mbins = np.arange(-(Nm // 2), -(Nm // 2) + Nm) % nm
111
+ # Inverse fill index so nufft2d2 builds the oversampled grid by a GATHER, not an
112
+ # fp64/complex SCATTER (`C.at[].set`) -- the scatter was 97% of the off-grid forward
113
+ # on GPU (~25000x the gather; see scripts/profile_offgrid_forward.py). Centered modes
114
+ # at sigma>=2 are collision-free in the oversampled grid, so the gather reproduces the
115
+ # scatter exactly (asserted below).
116
+ dest = (kbins[:, None] * nm + mbins[None, :]).ravel()
117
+ if np.unique(dest).size != Nk * Nm:
118
+ raise ValueError("NUFFT grid-build indices collide; gather build invalid")
119
+ flat = np.full(nk * nm, -1, dtype=np.int64)
120
+ flat[dest] = np.arange(Nk * Nm)
121
+ return NufftPlan(
122
+ Nk, Nm, nk, nm, W, bk, bm,
123
+ _correction(Nk, nk, W, bk),
124
+ _correction(Nm, nm, W, bm),
125
+ kbins, mbins,
126
+ np.where(flat >= 0, flat, 0), flat >= 0,
127
+ )
128
+
129
+
130
+ def _stencil(coord, n: int, W: int, beta: float):
131
+ """``(idx (Npts,W) mod n, weights (Npts,W))`` for the W-wide ES stencil.
132
+
133
+ The window base is ``stop_gradient``-frozen (locally constant); the weights
134
+ are smooth in ``coord`` -> native AD gives the analytic point-derivative.
135
+ """
136
+ xi = coord * n / TWO_PI
137
+ base = jax.lax.stop_gradient(jnp.ceil(xi - W / 2.0)).astype(jnp.int64)
138
+ off = jnp.arange(W)
139
+ ell = base[:, None] + off[None, :] # (Npts, W) true grid coords (un-wrapped)
140
+ w = _es_kernel(xi[:, None] - ell, beta, W)
141
+ return ell % n, w
142
+
143
+
144
+ def nufft2d2(plan: NufftPlan, coeffs, x, y):
145
+ """Type-2: ``f_j = sum_{k,m} coeffs[k,m] exp(i(k x_j + m y_j))``.
146
+
147
+ ``coeffs`` (Nk, Nm) complex (centered modes); ``x, y`` (Npts,) on ``[0,2pi)``.
148
+ """
149
+ c = coeffs / jnp.asarray(plan.corr_k)[:, None] / jnp.asarray(plan.corr_m)[None, :]
150
+ # Build the oversampled grid by a GATHER (the inverse fill index lives in the plan);
151
+ # the equivalent fp64/complex `.at[].set` scatter was 97% of the GPU forward.
152
+ C = jnp.where(
153
+ jnp.asarray(plan.fill_mask), c.ravel()[jnp.asarray(plan.fill_src)], 0.0 + 0.0j
154
+ ).reshape(plan.nk, plan.nm)
155
+ g = jnp.fft.ifft2(C) * (plan.nk * plan.nm)
156
+ lk, wk = _stencil(x, plan.nk, plan.W, plan.beta_k)
157
+ lm, wm = _stencil(y, plan.nm, plan.W, plan.beta_m)
158
+ gg = g[lk[:, :, None], lm[:, None, :]] # (Npts, W, W)
159
+ return jnp.sum(wk[:, :, None] * wm[:, None, :] * gg, axis=(1, 2))
160
+
161
+
162
+ def nufft2d1(plan: NufftPlan, vals, x, y):
163
+ """Type-1: ``coeffs[k,m] = sum_j vals_j exp(-i(k x_j + m y_j))`` -- transpose of type-2."""
164
+ lk, wk = _stencil(x, plan.nk, plan.W, plan.beta_k)
165
+ lm, wm = _stencil(y, plan.nm, plan.W, plan.beta_m)
166
+ contrib = (wk[:, :, None] * wm[:, None, :]) * jnp.asarray(vals)[:, None, None]
167
+ g = jnp.zeros((plan.nk, plan.nm), dtype=jnp.complex128)
168
+ g = g.at[lk[:, :, None], lm[:, None, :]].add(contrib)
169
+ C = jnp.fft.fft2(g)
170
+ cc = C[jnp.asarray(plan.kbins)[:, None], jnp.asarray(plan.mbins)[None, :]]
171
+ return cc / jnp.asarray(plan.corr_k)[:, None] / jnp.asarray(plan.corr_m)[None, :]