dsp-props 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.
- dsp_props/__init__.py +39 -0
- dsp_props/__main__.py +5 -0
- dsp_props/adapters.py +93 -0
- dsp_props/api.py +285 -0
- dsp_props/cli.py +123 -0
- dsp_props/core/__init__.py +19 -0
- dsp_props/core/calibration.py +116 -0
- dsp_props/core/iir.py +90 -0
- dsp_props/core/tolerance.py +74 -0
- dsp_props/generators/__init__.py +29 -0
- dsp_props/generators/signals.py +125 -0
- dsp_props/relations/__init__.py +45 -0
- dsp_props/relations/base.py +24 -0
- dsp_props/relations/lti.py +164 -0
- dsp_props/relations/multirate.py +123 -0
- dsp_props/relations/streaming.py +37 -0
- dsp_props/relations/transforms.py +185 -0
- dsp_props/report.py +194 -0
- dsp_props-0.1.0.dist-info/METADATA +221 -0
- dsp_props-0.1.0.dist-info/RECORD +23 -0
- dsp_props-0.1.0.dist-info/WHEEL +4 -0
- dsp_props-0.1.0.dist-info/entry_points.txt +2 -0
- dsp_props-0.1.0.dist-info/licenses/LICENSE +28 -0
dsp_props/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""dsp-props: property-based testing for digital signal processing code."""
|
|
2
|
+
|
|
3
|
+
from . import core, generators, relations
|
|
4
|
+
from .core import calibrate
|
|
5
|
+
from .api import (
|
|
6
|
+
PropertyViolation,
|
|
7
|
+
Report,
|
|
8
|
+
LTISpec,
|
|
9
|
+
MultirateSpec,
|
|
10
|
+
resampler,
|
|
11
|
+
StreamingSpec,
|
|
12
|
+
lti_filter,
|
|
13
|
+
TransformSpec,
|
|
14
|
+
stateful,
|
|
15
|
+
streaming,
|
|
16
|
+
transform,
|
|
17
|
+
verify,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"core",
|
|
24
|
+
"generators",
|
|
25
|
+
"relations",
|
|
26
|
+
"transform",
|
|
27
|
+
"calibrate",
|
|
28
|
+
"streaming",
|
|
29
|
+
"lti_filter",
|
|
30
|
+
"LTISpec",
|
|
31
|
+
"resampler",
|
|
32
|
+
"MultirateSpec",
|
|
33
|
+
"stateful",
|
|
34
|
+
"StreamingSpec",
|
|
35
|
+
"verify",
|
|
36
|
+
"Report",
|
|
37
|
+
"TransformSpec",
|
|
38
|
+
"PropertyViolation",
|
|
39
|
+
]
|
dsp_props/__main__.py
ADDED
dsp_props/adapters.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Adapters for DSP code written in C (shared libraries loaded with ctypes).
|
|
2
|
+
|
|
3
|
+
Two calling conventions are supported, both on contiguous ``double`` arrays:
|
|
4
|
+
|
|
5
|
+
* stateless: ``void f(const double *x, size_t n, double *y)``, where ``y`` holds
|
|
6
|
+
``out_length(n)`` samples (``n`` by default);
|
|
7
|
+
* block with state: ``void f(const double *x, size_t n, double *y, double *state)``,
|
|
8
|
+
where ``state`` holds ``state_size`` doubles carried from one block to the next.
|
|
9
|
+
|
|
10
|
+
The returned Python callables can then be declared like any other operator::
|
|
11
|
+
|
|
12
|
+
lib = build_shared("biquad.c")
|
|
13
|
+
lowpass = dp.lti_filter(transfer=(b, a))(c_function(lib, "biquad", state_size=2))
|
|
14
|
+
Block = dp.streaming()(c_block_processor(lib, "biquad", state_size=2))
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import ctypes
|
|
20
|
+
import os
|
|
21
|
+
import pathlib
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import tempfile
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
_DOUBLE_P = ctypes.POINTER(ctypes.c_double)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_shared(*sources, out=None, cc=None, flags=("-O2",)):
|
|
32
|
+
"""Compile C sources into a shared library and load it (needs a C compiler)."""
|
|
33
|
+
cc = cc or os.environ.get("CC") or shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
|
|
34
|
+
if cc is None:
|
|
35
|
+
raise RuntimeError("no C compiler found (set CC)")
|
|
36
|
+
if out is None:
|
|
37
|
+
out = pathlib.Path(tempfile.mkdtemp(prefix="dsp_props_")) / "libdsp.so"
|
|
38
|
+
cmd = [cc, *flags, "-shared", "-fPIC", "-o", str(out), *map(str, sources), "-lm"]
|
|
39
|
+
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
|
40
|
+
return ctypes.CDLL(str(out))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load(lib):
|
|
44
|
+
return lib if isinstance(lib, ctypes.CDLL) else ctypes.CDLL(str(lib))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _symbol(lib, name, with_state):
|
|
48
|
+
f = getattr(_load(lib), name)
|
|
49
|
+
f.restype = None
|
|
50
|
+
f.argtypes = [_DOUBLE_P, ctypes.c_size_t, _DOUBLE_P] + ([_DOUBLE_P] if with_state else [])
|
|
51
|
+
return f
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ptr(a):
|
|
55
|
+
return a.ctypes.data_as(_DOUBLE_P)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def c_function(lib, name, *, out_length=None, state_size=None):
|
|
59
|
+
"""A Python callable ``y = f(x)`` around a C function (state, if any, starts at zero)."""
|
|
60
|
+
f = _symbol(lib, name, state_size is not None)
|
|
61
|
+
out_length = out_length or (lambda n: n)
|
|
62
|
+
|
|
63
|
+
def call(x):
|
|
64
|
+
x = np.ascontiguousarray(x, dtype=np.float64)
|
|
65
|
+
y = np.zeros(out_length(len(x)))
|
|
66
|
+
if state_size is None:
|
|
67
|
+
f(_ptr(x), len(x), _ptr(y))
|
|
68
|
+
else:
|
|
69
|
+
state = np.zeros(state_size)
|
|
70
|
+
f(_ptr(x), len(x), _ptr(y), _ptr(state))
|
|
71
|
+
return y
|
|
72
|
+
|
|
73
|
+
call.__name__ = name
|
|
74
|
+
call.__qualname__ = name
|
|
75
|
+
return call
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def c_block_processor(lib, name, *, state_size, init_state=None):
|
|
79
|
+
"""A factory of block processors (``process(block)``) for ``dp.streaming``."""
|
|
80
|
+
f = _symbol(lib, name, True)
|
|
81
|
+
|
|
82
|
+
class Processor:
|
|
83
|
+
def __init__(self):
|
|
84
|
+
self.state = np.zeros(state_size) if init_state is None else np.array(init_state, dtype=float)
|
|
85
|
+
|
|
86
|
+
def process(self, block):
|
|
87
|
+
x = np.ascontiguousarray(block, dtype=np.float64)
|
|
88
|
+
y = np.zeros(len(x))
|
|
89
|
+
f(_ptr(x), len(x), _ptr(y), _ptr(self.state))
|
|
90
|
+
return y
|
|
91
|
+
|
|
92
|
+
Processor.__name__ = Processor.__qualname__ = name
|
|
93
|
+
return Processor
|
dsp_props/api.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Declarative API: describe an operator once, check every applicable relation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Callable, Optional
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from hypothesis import HealthCheck, given, settings
|
|
12
|
+
from hypothesis.errors import FlakyFailure, HypothesisException
|
|
13
|
+
|
|
14
|
+
from .core.tolerance import FFTTolerance, ToleranceModel
|
|
15
|
+
from .report import Failure, PropertyViolation, Report
|
|
16
|
+
from .relations import LTI_RELATIONS, MULTIRATE_RELATIONS, STREAMING_RELATIONS, TRANSFORM_RELATIONS
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
KINDS = ("dft", "rdft", "generic")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def hermitian_extend(half, n):
|
|
23
|
+
"""Full length-``n`` DFT from the ``n // 2 + 1`` bins of a real-input DFT."""
|
|
24
|
+
half = np.asarray(half)
|
|
25
|
+
full = np.empty(n, dtype=np.result_type(half.dtype, np.complex64))
|
|
26
|
+
m = n // 2 + 1
|
|
27
|
+
full[:m] = half[:m]
|
|
28
|
+
if n > m:
|
|
29
|
+
full[m:] = np.conj(half[1 : n - m + 1][::-1])
|
|
30
|
+
return full
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class TransformSpec:
|
|
35
|
+
func: Callable
|
|
36
|
+
inverse: Optional[Callable] = None
|
|
37
|
+
kind: str = "dft"
|
|
38
|
+
linear: bool = True
|
|
39
|
+
parseval: bool = False
|
|
40
|
+
parseval_scale: Callable[[int], float] = lambda n: n
|
|
41
|
+
real_input_hermitian: bool = False
|
|
42
|
+
real_input_only: bool = False
|
|
43
|
+
inverse_takes_length: bool = False
|
|
44
|
+
tolerance: ToleranceModel = field(default_factory=FFTTolerance)
|
|
45
|
+
max_n: int = 512
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def relations(self):
|
|
49
|
+
return [r for r in TRANSFORM_RELATIONS if r.applies(self)]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_fourier(self):
|
|
53
|
+
return self.kind in ("dft", "rdft")
|
|
54
|
+
|
|
55
|
+
def full(self, x):
|
|
56
|
+
"""The transform of ``x`` as a full spectrum (rdft outputs are Hermitian-extended)."""
|
|
57
|
+
y = np.asarray(self.func(x))
|
|
58
|
+
if self.kind == "rdft":
|
|
59
|
+
n = len(x)
|
|
60
|
+
if y.shape != (n // 2 + 1,):
|
|
61
|
+
raise AssertionError(f"rdft output has shape {y.shape}, expected ({n // 2 + 1},) for N={n}")
|
|
62
|
+
return hermitian_extend(y, n)
|
|
63
|
+
return y
|
|
64
|
+
|
|
65
|
+
def invert(self, X, n):
|
|
66
|
+
return np.asarray(self.inverse(X, n) if self.inverse_takes_length else self.inverse(X))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class StreamingSpec:
|
|
71
|
+
make: Callable
|
|
72
|
+
tolerance: ToleranceModel = field(default_factory=lambda: FFTTolerance(c=1000.0))
|
|
73
|
+
max_n: int = 512
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def relations(self):
|
|
77
|
+
return [r for r in STREAMING_RELATIONS if r.applies(self)]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class LTISpec:
|
|
82
|
+
func: Callable
|
|
83
|
+
causal: bool = True
|
|
84
|
+
transfer: Optional[tuple] = None
|
|
85
|
+
max_gain: Optional[float] = None
|
|
86
|
+
tolerance: ToleranceModel = field(default_factory=lambda: FFTTolerance(c=1000.0))
|
|
87
|
+
max_n: int = 512
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def relations(self):
|
|
91
|
+
return [r for r in LTI_RELATIONS if r.applies(self)]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def lti_filter(*, causal=True, transfer=None, max_gain=None, tolerance=None, max_n=512):
|
|
95
|
+
"""Declare a linear time-invariant filter applied with zero initial state.
|
|
96
|
+
|
|
97
|
+
Checks linearity, time invariance and (if ``causal``) causality. With
|
|
98
|
+
``transfer=(b, a)`` it also checks the measured frequency response against
|
|
99
|
+
B(z)/A(z), and the tolerance defaults to ``IIRTolerance(b, a)``, which
|
|
100
|
+
follows the conditioning of the recursion. With ``max_gain`` it checks
|
|
101
|
+
that no input bounded by 1 gives an output above ``max_gain`` (BIBO
|
|
102
|
+
stability with a declared bound, e.g. the l1 norm of the impulse response).
|
|
103
|
+
"""
|
|
104
|
+
if tolerance is None:
|
|
105
|
+
if transfer is not None:
|
|
106
|
+
from .core.iir import IIRTolerance
|
|
107
|
+
|
|
108
|
+
tolerance = IIRTolerance(*transfer)
|
|
109
|
+
else:
|
|
110
|
+
tolerance = FFTTolerance(c=1000.0)
|
|
111
|
+
|
|
112
|
+
def decorate(func):
|
|
113
|
+
func.__dsp_props__ = LTISpec(func=func, causal=causal, transfer=transfer, max_gain=max_gain,
|
|
114
|
+
tolerance=tolerance, max_n=max_n)
|
|
115
|
+
return func
|
|
116
|
+
|
|
117
|
+
return decorate
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class MultirateSpec:
|
|
122
|
+
func: Callable
|
|
123
|
+
up: int = 1
|
|
124
|
+
down: int = 1
|
|
125
|
+
causal: bool = False
|
|
126
|
+
length: Optional[Callable[[int], int]] = None
|
|
127
|
+
dc_gain: Optional[float] = 1.0
|
|
128
|
+
ripple: float = 1e-2
|
|
129
|
+
stopband: Optional[float] = 0.1
|
|
130
|
+
tolerance: ToleranceModel = field(default_factory=lambda: FFTTolerance(c=1000.0))
|
|
131
|
+
max_n: int = 512
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def relations(self):
|
|
135
|
+
return [r for r in MULTIRATE_RELATIONS if r.applies(self)]
|
|
136
|
+
|
|
137
|
+
def expected_length(self, n):
|
|
138
|
+
return self.length(n)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def resampler(up=1, down=1, *, causal=False, length="ceil", dc_gain=1.0, ripple=1e-2,
|
|
142
|
+
stopband=0.1, tolerance=None, max_n=512):
|
|
143
|
+
"""Declare a rational resampler (up by ``up``, down by ``down``), like ``resample_poly``.
|
|
144
|
+
|
|
145
|
+
Checks linearity and periodic time invariance (an input delay of
|
|
146
|
+
``down`` samples gives an output delay of ``up`` samples). Also, unless
|
|
147
|
+
disabled with ``None``: the output length (``"ceil"``: ceil(N up / down),
|
|
148
|
+
``"floor"``, or a callable), the DC gain within ``ripple`` away from the
|
|
149
|
+
edges, and, when decimating, that a tone above the output Nyquist
|
|
150
|
+
frequency comes out below ``stopband`` (relative amplitude).
|
|
151
|
+
"""
|
|
152
|
+
if up < 1 or down < 1:
|
|
153
|
+
raise ValueError("up and down must be positive integers")
|
|
154
|
+
rules = {
|
|
155
|
+
"ceil": lambda n: -(-n * up // down),
|
|
156
|
+
"floor": lambda n: n * up // down,
|
|
157
|
+
None: None,
|
|
158
|
+
}
|
|
159
|
+
rule = rules[length] if (length is None or isinstance(length, str)) else length
|
|
160
|
+
|
|
161
|
+
def decorate(func):
|
|
162
|
+
func.__dsp_props__ = MultirateSpec(func=func, up=up, down=down, causal=causal, length=rule,
|
|
163
|
+
dc_gain=dc_gain, ripple=ripple, stopband=stopband,
|
|
164
|
+
tolerance=tolerance or FFTTolerance(c=1000.0), max_n=max_n)
|
|
165
|
+
return func
|
|
166
|
+
|
|
167
|
+
return decorate
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def streaming(*, tolerance=None, max_n=512):
|
|
171
|
+
"""Declare a block processor: a class (or factory) whose instances have ``process(block)``.
|
|
172
|
+
|
|
173
|
+
``dp.verify`` then checks that any split into blocks, including empty
|
|
174
|
+
blocks and single samples, gives the same output as one call.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def decorate(make):
|
|
178
|
+
make.__dsp_props__ = StreamingSpec(make=make, tolerance=tolerance or FFTTolerance(c=1000.0),
|
|
179
|
+
max_n=max_n)
|
|
180
|
+
return make
|
|
181
|
+
|
|
182
|
+
return decorate
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class _StatefulProcessor:
|
|
186
|
+
def __init__(self, func, init_state):
|
|
187
|
+
self.func = func
|
|
188
|
+
self.state = init_state() if callable(init_state) else np.array(init_state, copy=True)
|
|
189
|
+
|
|
190
|
+
def process(self, block):
|
|
191
|
+
y, self.state = self.func(block, self.state)
|
|
192
|
+
return y
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def stateful(func, init_state, **kwargs):
|
|
196
|
+
"""Wrap ``func(block, state) -> (out, state)`` (e.g. lfilter with zi) as a streaming operator."""
|
|
197
|
+
return streaming(**kwargs)(lambda: _StatefulProcessor(func, init_state))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def transform(inverse=None, *, kind="dft", linear=True, parseval=False, parseval_scale=None,
|
|
201
|
+
real_input_hermitian=False, real_input_only=None, inverse_takes_length=None,
|
|
202
|
+
tolerance=None, max_n=512):
|
|
203
|
+
"""Declare a function as a transform; ``dp.verify`` then checks it.
|
|
204
|
+
|
|
205
|
+
``kind="dft"``: complex DFT (shift and convolution theorems apply).
|
|
206
|
+
``kind="rdft"``: real-input DFT returning ``N // 2 + 1`` bins, like
|
|
207
|
+
``numpy.fft.rfft``; its inverse is called as ``inverse(X, N)``.
|
|
208
|
+
``kind="generic"``: any linear transform (DCT, wavelets, STFT...): only
|
|
209
|
+
linearity, Parseval and inversibility are checked.
|
|
210
|
+
"""
|
|
211
|
+
if kind not in KINDS:
|
|
212
|
+
raise ValueError(f"kind must be one of {KINDS}, got {kind!r}")
|
|
213
|
+
if real_input_only is None:
|
|
214
|
+
real_input_only = kind == "rdft"
|
|
215
|
+
if inverse_takes_length is None:
|
|
216
|
+
inverse_takes_length = kind == "rdft"
|
|
217
|
+
|
|
218
|
+
def decorate(func):
|
|
219
|
+
func.__dsp_props__ = TransformSpec(
|
|
220
|
+
func=func,
|
|
221
|
+
inverse=inverse,
|
|
222
|
+
kind=kind,
|
|
223
|
+
linear=linear,
|
|
224
|
+
parseval=parseval,
|
|
225
|
+
parseval_scale=parseval_scale or (lambda n: n),
|
|
226
|
+
real_input_hermitian=real_input_hermitian,
|
|
227
|
+
real_input_only=real_input_only,
|
|
228
|
+
inverse_takes_length=inverse_takes_length,
|
|
229
|
+
tolerance=tolerance or FFTTolerance(),
|
|
230
|
+
max_n=max_n,
|
|
231
|
+
)
|
|
232
|
+
return func
|
|
233
|
+
|
|
234
|
+
return decorate
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _hypothesis_test(rel, spec, max_examples, derandomize):
|
|
238
|
+
@settings(max_examples=max_examples, deadline=None, database=None,
|
|
239
|
+
derandomize=derandomize, suppress_health_check=list(HealthCheck),
|
|
240
|
+
print_blob=False)
|
|
241
|
+
@given(rel.inputs(spec))
|
|
242
|
+
def run(inputs):
|
|
243
|
+
trace["calls"] += 1
|
|
244
|
+
try:
|
|
245
|
+
rel.check(spec, inputs)
|
|
246
|
+
except Exception:
|
|
247
|
+
# Hypothesis replays the shrunk example last, so this ends as the minimal input.
|
|
248
|
+
trace["example"] = inputs
|
|
249
|
+
if trace["first_failure"] is None:
|
|
250
|
+
trace["first_failure"] = trace["calls"]
|
|
251
|
+
raise
|
|
252
|
+
|
|
253
|
+
trace = {"calls": 0, "example": None, "first_failure": None}
|
|
254
|
+
return run, trace
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def verify(func, *, max_examples=200, raise_on_failure=True, derandomize=False) -> Report:
|
|
258
|
+
"""Run every relation declared for ``func`` and return a report."""
|
|
259
|
+
spec = getattr(func, "__dsp_props__", None)
|
|
260
|
+
if spec is None:
|
|
261
|
+
raise TypeError(f"{func!r} has no dsp-props declaration (use @dp.transform, @dp.lti_filter, @dp.resampler or @dp.streaming)")
|
|
262
|
+
failures, checked, seconds = [], [], {}
|
|
263
|
+
for rel in spec.relations:
|
|
264
|
+
checked.append(rel.name)
|
|
265
|
+
run, trace = _hypothesis_test(rel, spec, max_examples, derandomize)
|
|
266
|
+
t0 = time.perf_counter()
|
|
267
|
+
error = None
|
|
268
|
+
try:
|
|
269
|
+
run()
|
|
270
|
+
except FlakyFailure as exc:
|
|
271
|
+
# the operator gave different results for the same input: hidden state
|
|
272
|
+
error = AssertionError(f"{rel.name}: non-deterministic operator (same input, different result): {exc}")
|
|
273
|
+
except HypothesisException:
|
|
274
|
+
raise # misuse of the framework, not a property violation
|
|
275
|
+
except Exception as exc: # noqa: BLE001 - every failure is reported
|
|
276
|
+
error = exc
|
|
277
|
+
seconds[rel.name] = time.perf_counter() - t0
|
|
278
|
+
if error is not None:
|
|
279
|
+
failures.append(Failure(rel.name, error, trace["example"], trace["first_failure"]))
|
|
280
|
+
report = Report(getattr(func, "__name__", repr(func)), checked, failures,
|
|
281
|
+
kind=type(spec).__name__.replace("Spec", "").lower(),
|
|
282
|
+
examples_per_relation=max_examples, seconds=seconds)
|
|
283
|
+
if failures and raise_on_failure:
|
|
284
|
+
raise PropertyViolation(report)
|
|
285
|
+
return report
|
dsp_props/cli.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Command line: find every declared operator in modules or files and verify it.
|
|
2
|
+
|
|
3
|
+
dsp-props check mypkg.filters # a module
|
|
4
|
+
dsp-props check src/filters.py # a file
|
|
5
|
+
dsp-props check mypkg.filters:my_fft # one operator
|
|
6
|
+
dsp-props check mypkg --json report.json --examples 500
|
|
7
|
+
|
|
8
|
+
Exit status: 0 if every operator passes, 1 if any relation fails, 2 on usage errors.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import importlib
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import pathlib
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from .api import verify
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_module(target):
|
|
26
|
+
path = pathlib.Path(target)
|
|
27
|
+
if path.suffix == ".py" and path.exists():
|
|
28
|
+
name = "_dsp_props_target_" + path.stem
|
|
29
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
30
|
+
module = importlib.util.module_from_spec(spec)
|
|
31
|
+
sys.path.insert(0, str(path.resolve().parent))
|
|
32
|
+
sys.modules[name] = module
|
|
33
|
+
spec.loader.exec_module(module)
|
|
34
|
+
return module
|
|
35
|
+
return importlib.import_module(target)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def discover(target):
|
|
39
|
+
"""``[(qualified name, operator)]`` for a ``module``, ``file.py`` or ``module:attribute`` target."""
|
|
40
|
+
module_part, _, attr = target.partition(":")
|
|
41
|
+
module = _load_module(module_part)
|
|
42
|
+
label = module_part if module_part.endswith(".py") else module.__name__
|
|
43
|
+
if attr:
|
|
44
|
+
obj = module
|
|
45
|
+
for part in attr.split("."):
|
|
46
|
+
obj = getattr(obj, part)
|
|
47
|
+
if not hasattr(obj, "__dsp_props__"):
|
|
48
|
+
raise SystemExit(f"dsp-props: {target} has no dsp-props declaration")
|
|
49
|
+
return [(f"{label}:{attr}", obj)]
|
|
50
|
+
found, seen = [], set()
|
|
51
|
+
for name in sorted(vars(module)):
|
|
52
|
+
obj = vars(module)[name]
|
|
53
|
+
if name.startswith("_") or id(obj) in seen:
|
|
54
|
+
continue
|
|
55
|
+
if getattr(obj, "__dsp_props__", None) is not None:
|
|
56
|
+
seen.add(id(obj))
|
|
57
|
+
found.append((f"{label}:{name}", obj))
|
|
58
|
+
elif isinstance(obj, dict): # registries such as {"name": operator}
|
|
59
|
+
for key, value in obj.items():
|
|
60
|
+
if getattr(value, "__dsp_props__", None) is not None and id(value) not in seen:
|
|
61
|
+
seen.add(id(value))
|
|
62
|
+
found.append((f"{label}:{name}[{key!r}]", value))
|
|
63
|
+
return found
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _check(args):
|
|
67
|
+
sys.path.insert(0, ".")
|
|
68
|
+
targets = []
|
|
69
|
+
for t in args.targets:
|
|
70
|
+
try:
|
|
71
|
+
targets += discover(t)
|
|
72
|
+
except (ImportError, AttributeError, FileNotFoundError) as exc:
|
|
73
|
+
print(f"dsp-props: cannot load {t}: {exc}", file=sys.stderr)
|
|
74
|
+
return 2
|
|
75
|
+
if not targets:
|
|
76
|
+
print("dsp-props: no declared operator found (use @dp.transform, @dp.lti_filter, ...)", file=sys.stderr)
|
|
77
|
+
return 2
|
|
78
|
+
|
|
79
|
+
reports, failed = [], 0
|
|
80
|
+
for name, op in targets:
|
|
81
|
+
t0 = time.perf_counter()
|
|
82
|
+
report = verify(op, max_examples=args.examples, raise_on_failure=False, derandomize=args.derandomize)
|
|
83
|
+
dt = time.perf_counter() - t0
|
|
84
|
+
failed += not report.ok
|
|
85
|
+
reports.append((name, report))
|
|
86
|
+
print(f"{'ok ' if report.ok else 'FAIL'} {name} ({len(report.checked)} relations, {dt:.1f}s)")
|
|
87
|
+
if not report.ok or args.verbose:
|
|
88
|
+
print("\n".join(" " + line for line in str(report).splitlines()[1:]))
|
|
89
|
+
|
|
90
|
+
print(f"\n{len(targets) - failed}/{len(targets)} operators pass")
|
|
91
|
+
if args.json:
|
|
92
|
+
data = {"dsp_props": __version__, "examples": args.examples,
|
|
93
|
+
"operators": [{**r.to_dict(), "target": n} for n, r in reports]}
|
|
94
|
+
pathlib.Path(args.json).write_text(json.dumps(data, indent=2))
|
|
95
|
+
if args.markdown:
|
|
96
|
+
pathlib.Path(args.markdown).write_text("\n\n".join(r.to_markdown() for _, r in reports) + "\n")
|
|
97
|
+
if args.html:
|
|
98
|
+
from .report import render_html
|
|
99
|
+
|
|
100
|
+
for n, r in reports:
|
|
101
|
+
r.func_name = n
|
|
102
|
+
pathlib.Path(args.html).write_text(render_html([r for _, r in reports]))
|
|
103
|
+
return 1 if failed else 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv=None):
|
|
107
|
+
parser = argparse.ArgumentParser(prog="dsp-props", description="Property-based checks for DSP code.")
|
|
108
|
+
parser.add_argument("--version", action="version", version=f"dsp-props {__version__}")
|
|
109
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
110
|
+
check = sub.add_parser("check", help="verify every declared operator in modules or files")
|
|
111
|
+
check.add_argument("targets", nargs="+", help="module, file.py, or module:attribute")
|
|
112
|
+
check.add_argument("--examples", type=int, default=200, help="inputs per relation (default 200)")
|
|
113
|
+
check.add_argument("--derandomize", action="store_true", help="reproducible inputs (for CI)")
|
|
114
|
+
check.add_argument("--json", help="write the full report as JSON")
|
|
115
|
+
check.add_argument("--markdown", help="write the report as Markdown")
|
|
116
|
+
check.add_argument("--html", help="write the report as a standalone HTML page")
|
|
117
|
+
check.add_argument("-v", "--verbose", action="store_true", help="list relations of passing operators too")
|
|
118
|
+
args = parser.parse_args(argv)
|
|
119
|
+
return _check(args)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
sys.exit(main())
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .calibration import (
|
|
2
|
+
CalibratedTolerance,
|
|
3
|
+
CalibrationError,
|
|
4
|
+
calibrate,
|
|
5
|
+
longdouble_dft,
|
|
6
|
+
relative_error,
|
|
7
|
+
)
|
|
8
|
+
from .iir import IIRTolerance, longdouble_lfilter, recursion_gain
|
|
9
|
+
from .tolerance import ToleranceModel, FFTTolerance, ToleranceError, assert_close, norm
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"IIRTolerance",
|
|
13
|
+
"longdouble_lfilter",
|
|
14
|
+
"recursion_gain",
|
|
15
|
+
"CalibratedTolerance",
|
|
16
|
+
"CalibrationError",
|
|
17
|
+
"calibrate",
|
|
18
|
+
"longdouble_dft",
|
|
19
|
+
"relative_error","ToleranceModel", "FFTTolerance", "ToleranceError", "assert_close", "norm"]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Empirical error models: calibrate a tolerance against extended precision.
|
|
2
|
+
|
|
3
|
+
A fixed formula such as c * eps * log2(N) cannot describe every
|
|
4
|
+
implementation: an FFT library may switch algorithm with the factorisation
|
|
5
|
+
of N (mixed radix, naive small DFTs, Bluestein), and its error then varies by
|
|
6
|
+
two orders of magnitude between neighbouring lengths. ``calibrate`` measures
|
|
7
|
+
the actual relative error of an operator against an extended-precision
|
|
8
|
+
reference for each length and turns it into a tolerance model.
|
|
9
|
+
|
|
10
|
+
Calibration doubles as a sanity check: an error far above what rounding can
|
|
11
|
+
explain (``max_plausible``) means the operator is wrong, not imprecise, and
|
|
12
|
+
calibration refuses to absorb it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from .tolerance import FFTTolerance, ToleranceError, ToleranceModel, norm
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def longdouble_dft(x):
|
|
25
|
+
"""Extended-precision DFT reference (NumPy computes FFTs in long double)."""
|
|
26
|
+
return np.fft.fft(np.asarray(x).astype(np.clongdouble))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _eps(dtype):
|
|
30
|
+
return float(np.finfo(np.result_type(dtype, np.float32)).eps)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def relative_error(op, reference, x):
|
|
34
|
+
"""||op(x) - reference(x)|| / (eps * ||reference(x)||), eps of op's output dtype."""
|
|
35
|
+
y = np.asarray(op(x))
|
|
36
|
+
ref = np.asarray(reference(x))
|
|
37
|
+
if y.shape != ref.shape:
|
|
38
|
+
raise ToleranceError(f"shape {y.shape} != reference shape {ref.shape}")
|
|
39
|
+
ref_norm = float(norm(ref.astype(np.complex128)))
|
|
40
|
+
if ref_norm == 0.0:
|
|
41
|
+
return 0.0 if norm(y) == 0 else np.inf
|
|
42
|
+
diff = (y.astype(np.clongdouble) - ref).astype(np.complex128)
|
|
43
|
+
return float(norm(diff)) / (_eps(y.dtype) * ref_norm)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class CalibratedTolerance(ToleranceModel):
|
|
48
|
+
"""Analytic bound, raised where measurements show the implementation is worse.
|
|
49
|
+
|
|
50
|
+
bound(N) = max(base.bound(N), margin * chained * eps * ratio(N) * ||y||)
|
|
51
|
+
|
|
52
|
+
``ratios[N]`` is the worst measured error for length N, in units of eps.
|
|
53
|
+
Unmeasured lengths fall back to the worst ratio over all measured lengths,
|
|
54
|
+
which keeps the model conservative. The analytic ``base`` model covers
|
|
55
|
+
the inputs calibration did not try.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
ratios: dict = field(default_factory=dict)
|
|
59
|
+
margin: float = 8.0
|
|
60
|
+
chained: int = 2 # relations chain a few operations (e.g. inverse(f(x)))
|
|
61
|
+
base: ToleranceModel = field(default_factory=FFTTolerance)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def worst(self):
|
|
65
|
+
return max(self.ratios.values(), default=1.0)
|
|
66
|
+
|
|
67
|
+
def ratio(self, n):
|
|
68
|
+
return max(self.ratios.get(n, self.worst), 1.0)
|
|
69
|
+
|
|
70
|
+
def bound(self, n, dtype, ref_norm):
|
|
71
|
+
measured = self.margin * self.chained * _eps(dtype) * self.ratio(n) * ref_norm
|
|
72
|
+
return max(self.base.bound(n, dtype, ref_norm), measured)
|
|
73
|
+
|
|
74
|
+
def summary(self, top=5):
|
|
75
|
+
worst = sorted(self.ratios.items(), key=lambda kv: -kv[1])[:top]
|
|
76
|
+
lines = [f"calibrated on {len(self.ratios)} lengths, worst ratio {self.worst:.1f} eps"]
|
|
77
|
+
lines += [f" N={n:<6} {r:8.1f} eps" for n, r in worst]
|
|
78
|
+
return "\n".join(lines)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CalibrationError(AssertionError):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def calibrate(op, reference=longdouble_dft, lengths=range(1, 257), trials=6, *,
|
|
86
|
+
margin=8.0, max_plausible=1e6, seed=0) -> CalibratedTolerance:
|
|
87
|
+
"""Measure ``op`` against ``reference`` on random signals of each length.
|
|
88
|
+
|
|
89
|
+
Raises ``CalibrationError`` when the error is too large to be rounding
|
|
90
|
+
(``max_plausible`` eps): calibrating a wrong operator would hide its bug.
|
|
91
|
+
"""
|
|
92
|
+
rng = np.random.default_rng(seed)
|
|
93
|
+
ratios = {}
|
|
94
|
+
for n in lengths:
|
|
95
|
+
worst = 0.0
|
|
96
|
+
for x in _probe_signals(n, trials, rng):
|
|
97
|
+
worst = max(worst, relative_error(op, reference, x))
|
|
98
|
+
if not worst <= max_plausible:
|
|
99
|
+
raise CalibrationError(
|
|
100
|
+
f"error of {worst:.3g} eps at N={n}: the operator disagrees with the reference "
|
|
101
|
+
"beyond any plausible rounding error"
|
|
102
|
+
)
|
|
103
|
+
ratios[n] = worst
|
|
104
|
+
return CalibratedTolerance(ratios=ratios, margin=margin)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _probe_signals(n, trials, rng):
|
|
108
|
+
"""Gaussian noise plus the structured signals that stress FFT rounding."""
|
|
109
|
+
t = np.arange(n)
|
|
110
|
+
for _ in range(trials):
|
|
111
|
+
yield rng.standard_normal(n)
|
|
112
|
+
x = np.zeros(n)
|
|
113
|
+
x[rng.integers(n)] = 1.0
|
|
114
|
+
yield x
|
|
115
|
+
yield np.cos(2 * np.pi * rng.uniform(0, 0.5) * t + rng.uniform(-np.pi, np.pi))
|
|
116
|
+
yield np.cos(np.pi * t) + 1e-6 * rng.standard_normal(n) # near Nyquist, small detail
|