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.
- jaxht-0.1.0.dist-info/METADATA +165 -0
- jaxht-0.1.0.dist-info/RECORD +15 -0
- jaxht-0.1.0.dist-info/WHEEL +4 -0
- jaxht-0.1.0.dist-info/licenses/LICENSE +21 -0
- jht/__init__.py +86 -0
- jht/_nufft.py +171 -0
- jht/_recursion.py +572 -0
- jht/_reference.py +143 -0
- jht/analysis.py +54 -0
- jht/diff.py +100 -0
- jht/healpix.py +350 -0
- jht/masked.py +383 -0
- jht/offgrid.py +209 -0
- jht/py.typed +0 -0
- jht/weights.py +101 -0
|
@@ -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,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, :]
|