Skip to content

Commit 7155006

Browse files
quentin kuttenkulerclaude
authored andcommitted
Add Python bindings and fix/extend documentation portal
Python package (ctypes, compiled from source at install time, no prebuilt binaries): bfft.rfft/irfft are drop-in equivalents to numpy.fft.rfft/irfft, and bfft.odft/iodft expose the half-bin-shifted real transform. Forward and inverse pairs round-trip and match numpy to floating-point precision. Docs: fix the Sphinx -W build (exclude the portal README from the toctree), add a root-level .readthedocs.yaml for ReadTheDocs discovery, and document the Python install/usage in README.md and a new documentation/python.md page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fe24eb7 commit 7155006

11 files changed

Lines changed: 473 additions & 1 deletion

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,9 @@
77
*.exe
88
.DS_Store
99
/build-cmake/
10+
/documentation/_build/
11+
/.venv-docs/
12+
/.venv/
13+
__pycache__/
14+
*.egg-info/
15+
/dist/

.readthedocs.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
version: 2
2+
3+
build:
4+
os: ubuntu-22.04
5+
tools:
6+
python: "3.11"
7+
8+
sphinx:
9+
configuration: documentation/conf.py
10+
11+
python:
12+
install:
13+
- requirements: documentation/requirements.txt

MANIFEST.in

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Ship the native sources so `pip install .` can compile the library from an sdist.
2+
recursive-include src *.cpp *.hpp *.h *.md *.txt
3+
recursive-include include *.h *.hpp
4+
include LICENSE
5+
include README.md

README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,60 @@ int main() {
182182
}
183183
```
184184

185+
## Python bindings
186+
187+
BFFT ships a small ctypes-based Python package exposing numpy-friendly drop-in
188+
transforms. No prebuilt binaries are distributed: `pip install` compiles the
189+
native library from source on your machine.
190+
191+
### Install
192+
193+
From a clone of the repository:
194+
195+
```sh
196+
pip install .
197+
```
198+
199+
This compiles `src/bfft.cpp` and `src/bodft.cpp` with your C++ compiler (override
200+
with the `CXX` environment variable) and bundles the resulting shared library
201+
inside the installed package. A C++17 compiler and NumPy are the only
202+
requirements.
203+
204+
Alternatively, build and install the native library system-wide first, then the
205+
Python loader will find it automatically:
206+
207+
```sh
208+
make && sudo make install PREFIX=/usr/local
209+
pip install .
210+
```
211+
212+
You can also point the loader at an explicit shared library with the
213+
`BFFT_LIBRARY` environment variable.
214+
215+
### Usage
216+
217+
```python
218+
import numpy as np
219+
import bfft
220+
221+
x = np.random.randn(1024) # power-of-two length
222+
223+
X = bfft.rfft(x) # == numpy.fft.rfft(x) -> N/2 + 1 bins
224+
x_back = bfft.irfft(X) # == numpy.fft.irfft(X) -> N samples
225+
226+
H = bfft.odft(x) # half-bin-shifted transform -> N/2 bins
227+
x_back2 = bfft.iodft(H) # inverse of odft -> N samples
228+
```
229+
230+
| Function | Equivalent | Notes |
231+
| --- | --- | --- |
232+
| `bfft.rfft(x)` | `numpy.fft.rfft(x)` | Power-of-two `N >= 4`. |
233+
| `bfft.irfft(X, n=None)` | `numpy.fft.irfft(X, n)` | `n` defaults to `2 * (len(X) - 1)`. |
234+
| `bfft.odft(x)` | half-bin phase shift + `rfft` | `H[k] = sum_n x[n] exp(-2j*pi*(k+1/2)*n/N)`, `N >= 2`. |
235+
| `bfft.iodft(H, n=None)` | inverse of `bfft.odft` | `n` defaults to `2 * len(H)`. |
236+
237+
All Python transforms operate on power-of-two lengths and double precision.
238+
185239
## Main API concepts
186240

187241
### Plans

bfft/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""BFFT: power-of-two real Fourier transforms with a numpy-friendly API.
2+
3+
Public functions:
4+
bfft.rfft(x) -- drop-in equivalent of numpy.fft.rfft for power-of-two N.
5+
bfft.irfft(x, n) -- drop-in equivalent of numpy.fft.irfft.
6+
bfft.odft(x) -- half-bin-shifted real transform (phase shift + rfft).
7+
bfft.iodft(x, n) -- inverse of odft.
8+
"""
9+
10+
from ._core import iodft, irfft, odft, rfft
11+
12+
__all__ = ["rfft", "irfft", "odft", "iodft"]
13+
__version__ = "0.1.0"

bfft/_core.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""ctypes binding to the BFFT shared library.
2+
3+
Loads the native library (bundled in this package, or a system install from
4+
`make install`) and exposes typed access to the forward transforms used by the
5+
public shim in :mod:`bfft`.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import ctypes
11+
import ctypes.util
12+
import glob
13+
import os
14+
import sys
15+
from pathlib import Path
16+
17+
import numpy as np
18+
19+
_PKG_DIR = Path(__file__).resolve().parent
20+
21+
22+
class _Complex(ctypes.Structure):
23+
_fields_ = [("re", ctypes.c_double), ("im", ctypes.c_double)]
24+
25+
26+
def _candidate_paths():
27+
# 1. Explicit override.
28+
env = os.environ.get("BFFT_LIBRARY")
29+
if env:
30+
yield env
31+
# 2. Library bundled inside this package (the pip-install path).
32+
for pat in ("_libbfft.so", "_libbfft.dylib", "_libbfft.dll"):
33+
for hit in glob.glob(str(_PKG_DIR / pat)):
34+
yield hit
35+
# 3. A build tree next to a source checkout (editable / dev use).
36+
for rel in ("../build/libbfft.so", "../build/libbfft.dylib"):
37+
p = _PKG_DIR / rel
38+
if p.exists():
39+
yield str(p)
40+
# 4. A system install from `make install`.
41+
found = ctypes.util.find_library("bfft")
42+
if found:
43+
yield found
44+
yield "libbfft.so"
45+
if sys.platform == "darwin":
46+
yield "libbfft.dylib"
47+
48+
49+
def _load_library() -> ctypes.CDLL:
50+
last_err = None
51+
for path in _candidate_paths():
52+
try:
53+
return ctypes.CDLL(path)
54+
except OSError as exc: # pragma: no cover - depends on platform
55+
last_err = exc
56+
raise OSError(
57+
"Could not locate the BFFT native library. Build it with `pip install .` "
58+
"or `make && make install`, or set BFFT_LIBRARY to the shared object. "
59+
f"Last error: {last_err}"
60+
)
61+
62+
63+
_lib = _load_library()
64+
65+
66+
def _decl(name, restype, argtypes):
67+
fn = getattr(_lib, name)
68+
fn.restype = restype
69+
fn.argtypes = argtypes
70+
return fn
71+
72+
73+
_dbl_p = ctypes.POINTER(ctypes.c_double)
74+
_cplx_p = ctypes.POINTER(_Complex)
75+
_plan_p = ctypes.c_void_p
76+
77+
# --- standard real FFT (bfft.h) ---
78+
_bfft_plan_create = _decl("bfft_plan_create", ctypes.c_int,
79+
[ctypes.c_size_t, ctypes.POINTER(_plan_p)])
80+
_bfft_plan_destroy = _decl("bfft_plan_destroy", None, [_plan_p])
81+
_bfft_plan_bins = _decl("bfft_plan_bins", ctypes.c_size_t, [_plan_p])
82+
_bfft_plan_work_size = _decl("bfft_plan_work_size", ctypes.c_size_t, [_plan_p])
83+
_bfft_plan_native_scratch_size = _decl(
84+
"bfft_plan_native_scratch_size", ctypes.c_size_t, [_plan_p])
85+
_bfft_forward = _decl("bfft_forward", ctypes.c_int,
86+
[_plan_p, _dbl_p, _cplx_p, _dbl_p, _cplx_p])
87+
_bfft_inverse = _decl("bfft_inverse", ctypes.c_int, [_plan_p, _cplx_p, _dbl_p])
88+
89+
# --- half-bin ODFT (bodft.h) ---
90+
_bodft_plan_create = _decl("bodft_plan_create", ctypes.c_int,
91+
[ctypes.c_size_t, ctypes.POINTER(_plan_p)])
92+
_bodft_plan_destroy = _decl("bodft_plan_destroy", None, [_plan_p])
93+
_bodft_plan_bins = _decl("bodft_plan_bins", ctypes.c_size_t, [_plan_p])
94+
_bodft_forward = _decl("bodft_forward", ctypes.c_int, [_plan_p, _dbl_p, _cplx_p])
95+
_bodft_inverse = _decl("bodft_inverse", ctypes.c_int, [_plan_p, _cplx_p, _dbl_p])
96+
97+
_OK = 0
98+
99+
100+
def _check(status, what):
101+
if status != _OK:
102+
raise RuntimeError(f"{what} failed with BFFT status {status}")
103+
104+
105+
def _as_f64_1d(x):
106+
a = np.ascontiguousarray(x, dtype=np.float64)
107+
if a.ndim != 1:
108+
raise ValueError("bfft transforms expect a 1-D real input array")
109+
return a
110+
111+
112+
def _as_c128_1d(x):
113+
a = np.ascontiguousarray(x, dtype=np.complex128)
114+
if a.ndim != 1:
115+
raise ValueError("bfft inverse transforms expect a 1-D complex spectrum")
116+
return a
117+
118+
119+
def _is_pow2(n):
120+
return n >= 1 and (n & (n - 1)) == 0
121+
122+
123+
# Plans are reusable per size; cache them so repeated calls avoid setup cost.
124+
_bfft_plans: dict[int, _plan_p] = {}
125+
_bodft_plans: dict[int, _plan_p] = {}
126+
127+
128+
def _bfft_plan(n):
129+
p = _bfft_plans.get(n)
130+
if p is None:
131+
p = _plan_p()
132+
_check(_bfft_plan_create(n, ctypes.byref(p)), "bfft_plan_create")
133+
_bfft_plans[n] = p
134+
return p
135+
136+
137+
def _bodft_plan(n):
138+
p = _bodft_plans.get(n)
139+
if p is None:
140+
p = _plan_p()
141+
_check(_bodft_plan_create(n, ctypes.byref(p)), "bodft_plan_create")
142+
_bodft_plans[n] = p
143+
return p
144+
145+
146+
def rfft(x):
147+
"""Real-to-complex FFT. Drop-in for :func:`numpy.fft.rfft` (power-of-two)."""
148+
a = _as_f64_1d(x)
149+
n = a.shape[0]
150+
if not _is_pow2(n) or n < 4:
151+
raise ValueError("bfft.rfft requires a power-of-two length N >= 4")
152+
plan = _bfft_plan(n)
153+
bins = _bfft_plan_bins(plan)
154+
out = np.empty(bins, dtype=np.complex128)
155+
work = np.empty(_bfft_plan_work_size(plan), dtype=np.float64)
156+
scratch = np.empty(_bfft_plan_native_scratch_size(plan), dtype=np.complex128)
157+
_check(_bfft_forward(
158+
plan,
159+
a.ctypes.data_as(_dbl_p),
160+
out.ctypes.data_as(_cplx_p),
161+
work.ctypes.data_as(_dbl_p),
162+
scratch.ctypes.data_as(_cplx_p),
163+
), "bfft_forward")
164+
return out
165+
166+
167+
def irfft(x, n=None):
168+
"""Inverse real FFT. Drop-in for :func:`numpy.fft.irfft` (power-of-two).
169+
170+
``x`` holds ``N/2 + 1`` complex bins. ``n`` is the length of the real output;
171+
when omitted it defaults to ``2 * (len(x) - 1)``, matching numpy."""
172+
a = _as_c128_1d(x)
173+
if n is None:
174+
n = 2 * (a.shape[0] - 1)
175+
if not _is_pow2(n) or n < 4:
176+
raise ValueError("bfft.irfft requires a power-of-two output length N >= 4")
177+
plan = _bfft_plan(n)
178+
bins = _bfft_plan_bins(plan)
179+
if a.shape[0] != bins:
180+
raise ValueError(
181+
f"bfft.irfft expected {bins} bins for N={n}, got {a.shape[0]}")
182+
out = np.empty(n, dtype=np.float64)
183+
_check(_bfft_inverse(
184+
plan,
185+
a.ctypes.data_as(_cplx_p),
186+
out.ctypes.data_as(_dbl_p),
187+
), "bfft_inverse")
188+
return out
189+
190+
191+
def odft(x):
192+
"""Half-bin-shifted real transform: H[k] = sum_n x[n] exp(-2j*pi*(k+1/2)*n/N),
193+
k = 0 .. N/2-1. Equivalent to a half-bin phase shift followed by an rfft."""
194+
a = _as_f64_1d(x)
195+
n = a.shape[0]
196+
if not _is_pow2(n) or n < 2:
197+
raise ValueError("bfft.odft requires a power-of-two length N >= 2")
198+
plan = _bodft_plan(n)
199+
bins = _bodft_plan_bins(plan)
200+
out = np.empty(bins, dtype=np.complex128)
201+
_check(_bodft_forward(
202+
plan,
203+
a.ctypes.data_as(_dbl_p),
204+
out.ctypes.data_as(_cplx_p),
205+
), "bodft_forward")
206+
return out
207+
208+
209+
def iodft(x, n=None):
210+
"""Inverse of :func:`odft`. Maps ``N/2`` packed half-bin bins back to the
211+
``N`` real samples. ``n`` defaults to ``2 * len(x)``."""
212+
a = _as_c128_1d(x)
213+
if n is None:
214+
n = 2 * a.shape[0]
215+
if not _is_pow2(n) or n < 2:
216+
raise ValueError("bfft.iodft requires a power-of-two output length N >= 2")
217+
plan = _bodft_plan(n)
218+
bins = _bodft_plan_bins(plan)
219+
if a.shape[0] != bins:
220+
raise ValueError(
221+
f"bfft.iodft expected {bins} bins for N={n}, got {a.shape[0]}")
222+
out = np.empty(n, dtype=np.float64)
223+
_check(_bodft_inverse(
224+
plan,
225+
a.ctypes.data_as(_cplx_p),
226+
out.ctypes.data_as(_dbl_p),
227+
), "bodft_inverse")
228+
return out

documentation/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
}
1414

1515
master_doc = "index"
16-
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
16+
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "README.md"]
1717
html_theme = "furo"
1818
html_title = "BFFT documentation"
1919

documentation/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ residue-domain filtering, and a half-bin BODFT transform.
1010
1111
installation
1212
quick-start
13+
python
1314
concepts
1415
examples
1516
```

0 commit comments

Comments
 (0)