wdm-transform 0.1.1__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,13 @@
1
+ """Public package exports for the WDM transform scaffold."""
2
+
3
+ from .backends import Backend, get_backend, register_backend
4
+ from .datatypes import FrequencySeries, TimeSeries, WDM
5
+
6
+ __all__ = [
7
+ "Backend",
8
+ "FrequencySeries",
9
+ "TimeSeries",
10
+ "WDM",
11
+ "get_backend",
12
+ "register_backend",
13
+ ]
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from importlib import import_module
5
+ from typing import Any
6
+
7
+ from .base import Backend
8
+ from .numpy_backend import NUMPY_BACKEND
9
+
10
+ _BACKENDS: dict[str, Backend] = {
11
+ NUMPY_BACKEND.name: NUMPY_BACKEND,
12
+ }
13
+
14
+ _BUILTIN_BACKEND_LOADERS = {
15
+ "jax": ("wdm_transform.backends.jax_backend", "load_jax_backend"),
16
+ "cupy": ("wdm_transform.backends.cupy_backend", "load_cupy_backend"),
17
+ }
18
+
19
+
20
+ def register_backend(name: str, xp: Any, fft: Any) -> Backend:
21
+ """Register a backend by name and return the resulting backend object."""
22
+ backend = Backend(name=name, xp=xp, fft=fft)
23
+ _BACKENDS[name] = backend
24
+ return backend
25
+
26
+
27
+ def _load_builtin_backend(name: str) -> Backend:
28
+ try:
29
+ module_name, loader_name = _BUILTIN_BACKEND_LOADERS[name]
30
+ except KeyError as exc:
31
+ raise ValueError(f"Unknown backend {name!r}.") from exc
32
+
33
+ module = import_module(module_name)
34
+ loader = getattr(module, loader_name)
35
+ backend = loader()
36
+ _BACKENDS[name] = backend
37
+ return backend
38
+
39
+
40
+ def get_backend(backend: str | Backend | None = None) -> Backend:
41
+ """Resolve a backend object from a name, instance, or environment default."""
42
+ if isinstance(backend, Backend):
43
+ return backend
44
+ if backend is None:
45
+ backend = os.environ.get("WDM_BACKEND")
46
+ if backend is None:
47
+ return NUMPY_BACKEND
48
+ try:
49
+ return _BACKENDS[backend]
50
+ except KeyError as exc:
51
+ try:
52
+ return _load_builtin_backend(backend)
53
+ except ValueError:
54
+ available = ", ".join(
55
+ sorted(set(_BACKENDS) | set(_BUILTIN_BACKEND_LOADERS))
56
+ )
57
+ raise ValueError(
58
+ f"Unknown backend {backend!r}. Available backends: {available}."
59
+ ) from exc
60
+
61
+
62
+ __all__ = ["Backend", "NUMPY_BACKEND", "get_backend", "register_backend"]
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Backend:
9
+ """Minimal backend wrapper around an array namespace and FFT namespace."""
10
+
11
+ name: str
12
+ xp: Any
13
+ fft: Any
14
+
15
+ def asarray(self, value: Any, dtype: Any | None = None) -> Any:
16
+ """Convert a value into an array for this backend."""
17
+ return self.xp.asarray(value, dtype=dtype)
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import Backend
4
+
5
+
6
+ def load_cupy_backend() -> Backend:
7
+ try:
8
+ import cupy as cp
9
+ import cupy.fft as cpfft
10
+ except ImportError as exc:
11
+ raise ImportError(
12
+ "CuPy backend requested, but cupy is not installed. Install a compatible cupy package first."
13
+ ) from exc
14
+
15
+ return Backend(name="cupy", xp=cp, fft=cpfft)
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import Backend
4
+
5
+
6
+ def load_jax_backend() -> Backend:
7
+ try:
8
+ import jax
9
+ jax.config.update("jax_enable_x64", True)
10
+ import jax.numpy as jnp
11
+ import jax.numpy.fft as jfft
12
+ except ImportError as exc:
13
+ raise ImportError(
14
+ "JAX backend requested, but jax is not installed. Install a compatible jax package first."
15
+ ) from exc
16
+
17
+ return Backend(name="jax", xp=jnp, fft=jfft)
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+ from .base import Backend
6
+
7
+
8
+ NUMPY_BACKEND = Backend(name="numpy", xp=np, fft=np.fft)
@@ -0,0 +1,284 @@
1
+ """Benchmarking CLI for WDM transforms across different backends and input sizes.
2
+
3
+ This module is excluded from pytest coverage and should not be imported
4
+ when loading the main wdm_transform package.
5
+
6
+ Run with: python -m wdm_transform.benchmarking [options]
7
+ """
8
+
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+
15
+ from . import backends, transforms
16
+
17
+
18
+ def validate_backend_available(backend_name: str) -> bool: # pragma: no cover
19
+ """Check if a backend is available."""
20
+ try:
21
+ backends.get_backend(backend_name)
22
+ return True
23
+ except ValueError:
24
+ return False
25
+
26
+
27
+ def generate_benchmark_data(n: int) -> np.ndarray: # pragma: no cover
28
+ """Generate random complex benchmark data."""
29
+ np.random.seed(42)
30
+ return np.random.randn(n) + 1j * np.random.randn(n)
31
+
32
+
33
+ def find_factorization(n: int) -> tuple[int, int] | None: # pragma: no cover
34
+ """Find a factorization n = nt * nf where both nt and nf are even.
35
+
36
+ Prioritizes balanced factorizations (nt close to sqrt(n)).
37
+ Returns (nt, nf) or None if no valid factorization exists.
38
+ """
39
+ if n < 4:
40
+ return None
41
+
42
+ # Try factorizations starting from sqrt(n) and working outward
43
+ sqrt_n = int(np.sqrt(n))
44
+
45
+ for nt in range(sqrt_n, 1, -1):
46
+ if n % nt != 0:
47
+ continue
48
+ nf = n // nt
49
+ # Both must be even
50
+ if nt % 2 == 0 and nf % 2 == 0:
51
+ return (nt, nf)
52
+
53
+ return None
54
+
55
+
56
+ def benchmark_forward(
57
+ data: np.ndarray,
58
+ backend_name: str,
59
+ nt: int,
60
+ nf: int,
61
+ num_runs: int = 3,
62
+ ) -> float: # pragma: no cover
63
+ """Benchmark the forward WDM transform.
64
+
65
+ Returns the mean runtime in seconds.
66
+ """
67
+ backend = backends.get_backend(backend_name)
68
+ fixed_params = {"a": 0.4, "d": 0.8, "dt": 1.0, "backend": backend}
69
+
70
+ # Convert data to backend array
71
+ backend_data = backend.asarray(data)
72
+
73
+ # Warmup run
74
+ transforms.forward_wdm(backend_data, nt=nt, nf=nf, **fixed_params)
75
+
76
+ # Timed runs
77
+ times = []
78
+ for _ in range(num_runs):
79
+ start = time.perf_counter()
80
+ transforms.forward_wdm(backend_data, nt=nt, nf=nf, **fixed_params)
81
+ elapsed = time.perf_counter() - start
82
+ times.append(elapsed)
83
+
84
+ return np.mean(times)
85
+
86
+
87
+ def benchmark_inverse(
88
+ coeffs: np.ndarray,
89
+ backend_name: str,
90
+ num_runs: int = 3,
91
+ ) -> float: # pragma: no cover
92
+ """Benchmark the inverse WDM transform.
93
+
94
+ Returns the mean runtime in seconds.
95
+ """
96
+ backend = backends.get_backend(backend_name)
97
+ fixed_params = {"a": 0.4, "d": 0.8, "dt": 1.0, "backend": backend}
98
+
99
+ # Convert coeffs to backend array
100
+ backend_coeffs = backend.asarray(coeffs)
101
+
102
+ # Warmup run
103
+ transforms.inverse_wdm(backend_coeffs, **fixed_params)
104
+
105
+ # Timed runs
106
+ times = []
107
+ for _ in range(num_runs):
108
+ start = time.perf_counter()
109
+ transforms.inverse_wdm(backend_coeffs, **fixed_params)
110
+ elapsed = time.perf_counter() - start
111
+ times.append(elapsed)
112
+
113
+ return np.mean(times)
114
+
115
+
116
+ def run_benchmarks( # pragma: no cover
117
+ backends_to_test: list[str],
118
+ n_values: list[int],
119
+ num_runs: int = 3,
120
+ ) -> dict[str, Any]:
121
+ """Run benchmark suite across backends and input sizes.
122
+
123
+ Returns results dictionary with structure:
124
+ {
125
+ 'forward': {backend_name: {n: time_in_seconds}},
126
+ 'inverse': {backend_name: {n: time_in_seconds}},
127
+ }
128
+ """
129
+ results = {"forward": {}, "inverse": {}}
130
+
131
+ # Validate backends
132
+ available_backends = [b for b in backends_to_test if validate_backend_available(b)]
133
+ if not available_backends:
134
+ print(f"ERROR: No backends available from: {backends_to_test}")
135
+ return results
136
+
137
+ if len(available_backends) < len(backends_to_test):
138
+ unavailable = set(backends_to_test) - set(available_backends)
139
+ print(f"WARNING: Skipping unavailable backends: {unavailable}")
140
+
141
+ # Forward transform benchmarks
142
+ print("\n" + "=" * 70)
143
+ print("FORWARD WDM TRANSFORM BENCHMARKS")
144
+ print("=" * 70)
145
+
146
+ for backend_name in available_backends:
147
+ results["forward"][backend_name] = {}
148
+ print(f"\nBackend: {backend_name}")
149
+ print("-" * 40)
150
+
151
+ for n in n_values:
152
+ # Find a valid factorization for this N
153
+ factorization = find_factorization(n)
154
+ if factorization is None:
155
+ print(f" N={n:>8}: SKIPPED (no valid factorization)")
156
+ continue
157
+
158
+ nt, nf = factorization
159
+ data = generate_benchmark_data(nt * nf)
160
+ try:
161
+ mean_time = benchmark_forward(data, backend_name, nt, nf, num_runs)
162
+ results["forward"][backend_name][n] = mean_time
163
+ print(f" N={n:>8}: {mean_time*1e3:>10.4f} ms (nt={nt}, nf={nf})")
164
+ except Exception as e:
165
+ print(f" N={n:>8}: FAILED ({type(e).__name__}: {e})")
166
+
167
+ # Inverse transform benchmarks
168
+ print("\n" + "=" * 70)
169
+ print("INVERSE WDM TRANSFORM BENCHMARKS")
170
+ print("=" * 70)
171
+
172
+ for backend_name in available_backends:
173
+ results["inverse"][backend_name] = {}
174
+ print(f"\nBackend: {backend_name}")
175
+ print("-" * 40)
176
+
177
+ for n in n_values:
178
+ # Find a valid factorization for this N
179
+ factorization = find_factorization(n)
180
+ if factorization is None:
181
+ print(f" N={n:>8}: SKIPPED (no valid factorization)")
182
+ continue
183
+
184
+ nt, nf = factorization
185
+ coeffs = generate_benchmark_data(nt * nf).reshape(nt, nf)
186
+ try:
187
+ mean_time = benchmark_inverse(coeffs, backend_name, num_runs)
188
+ results["inverse"][backend_name][n] = mean_time
189
+ print(f" N={n:>8}: {mean_time*1e3:>10.4f} ms (nt={nt}, nf={nf})")
190
+ except Exception as e:
191
+ print(f" N={n:>8}: FAILED ({type(e).__name__}: {e})")
192
+
193
+ return results
194
+
195
+
196
+ def print_summary(results: dict[str, Any]) -> None: # pragma: no cover
197
+ """Print a summary table of results."""
198
+ print("\n" + "=" * 70)
199
+ print("SUMMARY TABLE")
200
+ print("=" * 70)
201
+
202
+ for transform_type, backends_data in results.items():
203
+ if not backends_data:
204
+ continue
205
+
206
+ print(f"\n{transform_type.upper()} TRANSFORM:")
207
+ print("-" * 40)
208
+
209
+ # Get all N values
210
+ all_n = set()
211
+ for n_dict in backends_data.values():
212
+ all_n.update(n_dict.keys())
213
+ all_n = sorted(all_n)
214
+
215
+ # Header
216
+ header = f"{'N':>10} | " + " | ".join(f"{b:>12}" for b in backends_data.keys())
217
+ print(header)
218
+ print("-" * len(header))
219
+
220
+ # Rows
221
+ for n in all_n:
222
+ row = f"{n:>10} | "
223
+ times = [backends_data.get(b, {}).get(n) for b in backends_data.keys()]
224
+ time_strs = [
225
+ f"{t*1e3:>10.4f} ms" if t is not None else f"{'SKIPPED':>12}" for t in times
226
+ ]
227
+ row += " | ".join(time_strs)
228
+ print(row)
229
+
230
+
231
+ def main() -> None: # pragma: no cover
232
+ """CLI entry point for benchmarking."""
233
+ import argparse
234
+
235
+ parser = argparse.ArgumentParser(
236
+ description="Benchmark WDM transforms across backends and input sizes.",
237
+ formatter_class=argparse.RawDescriptionHelpFormatter,
238
+ epilog="""
239
+ Examples:
240
+ python -m wdm_transform.benchmarking
241
+ python -m wdm_transform.benchmarking --backends numpy jax
242
+ python -m wdm_transform.benchmarking --n 2048 4096 8192
243
+ python -m wdm_transform.benchmarking --runs 5
244
+ """,
245
+ )
246
+
247
+ parser.add_argument(
248
+ "--backends",
249
+ nargs="+",
250
+ default=["numpy"],
251
+ choices=["numpy", "jax", "cupy"],
252
+ help="Backends to benchmark (default: numpy)",
253
+ )
254
+
255
+ parser.add_argument(
256
+ "--n",
257
+ nargs="+",
258
+ type=int,
259
+ default=[2048, 4096, 8192, 16384],
260
+ help="Input sizes to test (default: 2048 4096 8192 16384)",
261
+ )
262
+
263
+ parser.add_argument(
264
+ "--runs",
265
+ type=int,
266
+ default=3,
267
+ help="Number of runs per benchmark (default: 3)",
268
+ )
269
+
270
+ args = parser.parse_args()
271
+
272
+ results = run_benchmarks(
273
+ backends_to_test=args.backends,
274
+ n_values=args.n,
275
+ num_runs=args.runs,
276
+ )
277
+
278
+ print_summary(results)
279
+ print("\n")
280
+
281
+
282
+ if __name__ == "__main__": # pragma: no cover
283
+ main()
284
+
@@ -0,0 +1,4 @@
1
+ from .series import FrequencySeries, TimeSeries
2
+ from .wdm import WDM
3
+
4
+ __all__ = ["FrequencySeries", "TimeSeries", "WDM"]
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from ..backends import Backend, get_backend
7
+
8
+ if TYPE_CHECKING:
9
+ from .wdm import WDM
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class TimeSeries:
14
+ """One-dimensional sampled time-domain data."""
15
+
16
+ data: Any
17
+ dt: float
18
+ backend: Backend = field(default_factory=get_backend)
19
+
20
+ def __post_init__(self) -> None:
21
+ backend = get_backend(self.backend)
22
+ data = backend.asarray(self.data)
23
+
24
+ if data.ndim != 1:
25
+ raise ValueError("TimeSeries data must be one-dimensional.")
26
+ if self.dt <= 0:
27
+ raise ValueError("dt must be positive.")
28
+
29
+ object.__setattr__(self, "backend", backend)
30
+ object.__setattr__(self, "data", data)
31
+
32
+ @property
33
+ def n(self) -> int:
34
+ return int(self.data.shape[0])
35
+
36
+ @property
37
+ def df(self) -> float:
38
+ return 1.0 / (self.n * self.dt)
39
+
40
+ @property
41
+ def times(self) -> Any:
42
+ return self.backend.xp.arange(self.n) * self.dt
43
+
44
+ def to_frequency_series(self) -> "FrequencySeries":
45
+ transformed = self.backend.fft.fft(self.data)
46
+ return FrequencySeries(transformed, df=self.df, backend=self.backend)
47
+
48
+ def to_wdm(
49
+ self,
50
+ *,
51
+ nt: int,
52
+ a: float = 1.0 / 3.0,
53
+ d: float = 1.0,
54
+ backend: str | Backend | None = None,
55
+ ) -> "WDM":
56
+ """Compute the WDM transform of this time series."""
57
+ from .wdm import WDM
58
+
59
+ return WDM.from_time_series(self, nt=nt, a=a, d=d, backend=backend)
60
+
61
+ def plot(self, **kwargs: Any) -> tuple[Any, Any]:
62
+ """Plot the time-domain samples using the shared plotting helper."""
63
+ from ..plotting import plot_time_series
64
+
65
+ return plot_time_series(self, **kwargs)
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class FrequencySeries:
70
+ """One-dimensional FFT-domain data with spacing metadata."""
71
+
72
+ data: Any
73
+ df: float
74
+ backend: Backend = field(default_factory=get_backend)
75
+
76
+ def __post_init__(self) -> None:
77
+ backend = get_backend(self.backend)
78
+ data = backend.asarray(self.data)
79
+
80
+ if data.ndim != 1:
81
+ raise ValueError("FrequencySeries data must be one-dimensional.")
82
+ if self.df <= 0:
83
+ raise ValueError("df must be positive.")
84
+
85
+ object.__setattr__(self, "backend", backend)
86
+ object.__setattr__(self, "data", data)
87
+
88
+ @property
89
+ def n(self) -> int:
90
+ return int(self.data.shape[0])
91
+
92
+ @property
93
+ def dt(self) -> float:
94
+ return 1.0 / (self.n * self.df)
95
+
96
+ @property
97
+ def freqs(self) -> Any:
98
+ return self.backend.fft.fftfreq(self.n, d=self.dt)
99
+
100
+ def to_time_series(self, *, real: bool = False) -> TimeSeries:
101
+ recovered = self.backend.fft.ifft(self.data)
102
+ if real:
103
+ recovered = self.backend.xp.real(recovered)
104
+ return TimeSeries(recovered, dt=self.dt, backend=self.backend)
105
+
106
+ def to_wdm(
107
+ self,
108
+ *,
109
+ nt: int,
110
+ a: float = 1.0 / 3.0,
111
+ d: float = 1.0,
112
+ backend: str | Backend | None = None,
113
+ ) -> "WDM":
114
+ """Compute the WDM transform of this frequency-domain series."""
115
+ from .wdm import WDM
116
+
117
+ return WDM.from_frequency_series(self, nt=nt, a=a, d=d, backend=backend)
118
+
119
+ def plot(self, **kwargs: Any) -> tuple[Any, Any]:
120
+ """Plot the spectrum using the shared plotting helper."""
121
+ from ..plotting import plot_frequency_series
122
+
123
+ return plot_frequency_series(self, **kwargs)