|
| 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 |
0 commit comments