dsp-props 0.1.0__tar.gz
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-0.1.0/.gitignore +8 -0
- dsp_props-0.1.0/CITATION.cff +12 -0
- dsp_props-0.1.0/LICENSE +28 -0
- dsp_props-0.1.0/PKG-INFO +221 -0
- dsp_props-0.1.0/README.md +187 -0
- dsp_props-0.1.0/examples/c/biquad.c +43 -0
- dsp_props-0.1.0/examples/filters.py +29 -0
- dsp_props-0.1.0/pyproject.toml +54 -0
- dsp_props-0.1.0/src/dsp_props/__init__.py +39 -0
- dsp_props-0.1.0/src/dsp_props/__main__.py +5 -0
- dsp_props-0.1.0/src/dsp_props/adapters.py +93 -0
- dsp_props-0.1.0/src/dsp_props/api.py +285 -0
- dsp_props-0.1.0/src/dsp_props/cli.py +123 -0
- dsp_props-0.1.0/src/dsp_props/core/__init__.py +19 -0
- dsp_props-0.1.0/src/dsp_props/core/calibration.py +116 -0
- dsp_props-0.1.0/src/dsp_props/core/iir.py +90 -0
- dsp_props-0.1.0/src/dsp_props/core/tolerance.py +74 -0
- dsp_props-0.1.0/src/dsp_props/generators/__init__.py +29 -0
- dsp_props-0.1.0/src/dsp_props/generators/signals.py +125 -0
- dsp_props-0.1.0/src/dsp_props/relations/__init__.py +45 -0
- dsp_props-0.1.0/src/dsp_props/relations/base.py +24 -0
- dsp_props-0.1.0/src/dsp_props/relations/lti.py +164 -0
- dsp_props-0.1.0/src/dsp_props/relations/multirate.py +123 -0
- dsp_props-0.1.0/src/dsp_props/relations/streaming.py +37 -0
- dsp_props-0.1.0/src/dsp_props/relations/transforms.py +185 -0
- dsp_props-0.1.0/src/dsp_props/report.py +194 -0
- dsp_props-0.1.0/tests/test_adapters.py +47 -0
- dsp_props-0.1.0/tests/test_calibration.py +41 -0
- dsp_props-0.1.0/tests/test_cli.py +38 -0
- dsp_props-0.1.0/tests/test_generators.py +21 -0
- dsp_props-0.1.0/tests/test_iir.py +37 -0
- dsp_props-0.1.0/tests/test_multirate.py +43 -0
- dsp_props-0.1.0/tests/test_numpy_fft.py +40 -0
- dsp_props-0.1.0/tests/test_report.py +43 -0
- dsp_props-0.1.0/tests/test_streaming.py +41 -0
- dsp_props-0.1.0/tests/test_tolerance.py +39 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "Si vous utilisez dsp-props, merci de le citer comme suit."
|
|
3
|
+
title: "dsp-props: property-based testing for digital signal processing"
|
|
4
|
+
type: software
|
|
5
|
+
authors:
|
|
6
|
+
- family-names: "Basdevant"
|
|
7
|
+
given-names: "Clément"
|
|
8
|
+
orcid: "https://orcid.org/0009-0004-7496-2365"
|
|
9
|
+
license: BSD-3-Clause
|
|
10
|
+
repository-code: "https://github.com/clement-basdevant/dsp-props"
|
|
11
|
+
version: 0.1.0
|
|
12
|
+
date-released: 2026-10-14
|
dsp_props-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, dsp-props contributors
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
dsp_props-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dsp-props
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Property-based testing for digital signal processing code
|
|
5
|
+
Project-URL: Homepage, https://github.com/clement-basdevant/dsp-props
|
|
6
|
+
Project-URL: Source, https://github.com/clement-basdevant/dsp-props
|
|
7
|
+
Project-URL: Issues, https://github.com/clement-basdevant/dsp-props/issues
|
|
8
|
+
Author: Clément Basdevant
|
|
9
|
+
License-Expression: BSD-3-Clause
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: DSP,FFT,filters,hypothesis,metamorphic testing,property-based testing,signal processing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Hypothesis
|
|
14
|
+
Classifier: Framework :: Pytest
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: hypothesis>=6.80
|
|
25
|
+
Requires-Dist: numpy>=1.24
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: build; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
29
|
+
Requires-Dist: scipy>=1.11; extra == 'dev'
|
|
30
|
+
Requires-Dist: twine; extra == 'dev'
|
|
31
|
+
Provides-Extra: scipy
|
|
32
|
+
Requires-Dist: scipy>=1.11; extra == 'scipy'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# dsp-props
|
|
36
|
+
|
|
37
|
+
**Tests de propriété pour le traitement du signal** · *Property-based testing for DSP code*
|
|
38
|
+
|
|
39
|
+
Le code DSP est souvent testé en comparant sa sortie à une référence (MATLAB, SciPy) sur quelques signaux, avec une tolérance choisie à la main. `dsp-props` vérifie plutôt des **relations qui doivent toujours tenir** (linéarité, théorème du décalage, Parseval, symétrie hermitienne, inversibilité, théorème de convolution), sur des **signaux générés automatiquement** par [Hypothesis](https://hypothesis.works/) et choisis pour piéger le code : impulsions, sinusoïdes hors grille, tons proches, quasi-Nyquist, amplitudes extrêmes, longueurs 1, premières ou 2^k ± 1. Les tolérances dépendent de l'opération (erreur en O(ε log N) pour une FFT) au lieu d'un `atol` fixe.
|
|
40
|
+
|
|
41
|
+
> Statut : pré-alpha, phase 1 du [schéma directeur](docs/schema-directeur.md).
|
|
42
|
+
|
|
43
|
+
## Exemple
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import numpy as np
|
|
47
|
+
import dsp_props as dp
|
|
48
|
+
|
|
49
|
+
@dp.transform(inverse=np.fft.ifft, parseval=True, real_input_hermitian=True)
|
|
50
|
+
def my_fft(x):
|
|
51
|
+
return np.fft.fft(x)
|
|
52
|
+
|
|
53
|
+
print(dp.verify(my_fft))
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
dsp-props report for my_fft
|
|
58
|
+
ok linearity
|
|
59
|
+
ok circular shift
|
|
60
|
+
ok parseval
|
|
61
|
+
ok hermitian symmetry
|
|
62
|
+
ok inversibility
|
|
63
|
+
ok convolution theorem
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
En cas d'échec, `verify` lève `PropertyViolation` avec, pour chaque relation violée, le contre-exemple réduit par Hypothesis.
|
|
67
|
+
|
|
68
|
+
Autres déclarations :
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
# FFT réelle : l'inverse reçoit la longueur N
|
|
72
|
+
dp.transform(inverse=np.fft.irfft, kind="rdft", parseval=True)(np.fft.rfft)
|
|
73
|
+
|
|
74
|
+
# Transformée linéaire quelconque (DCT, ondelettes...)
|
|
75
|
+
dp.transform(kind="generic", inverse=idct_ortho, parseval=True, parseval_scale=lambda n: 1.0)(dct_ortho)
|
|
76
|
+
|
|
77
|
+
# Filtre LTI : linéarité, invariance temporelle, causalité, réponse en fréquence = B(z)/A(z).
|
|
78
|
+
# La tolérance suit le conditionnement de la récursion (IIRTolerance).
|
|
79
|
+
dp.lti_filter(causal=True, transfer=(b, a))(lambda x: scipy.signal.lfilter(b, a, x))
|
|
80
|
+
|
|
81
|
+
# Stabilité EBSB : aucune entrée bornée par 1 ne doit dépasser le gain déclaré (entrée pire cas construite)
|
|
82
|
+
dp.lti_filter(max_gain=l1_de_h)(mon_filtre)
|
|
83
|
+
|
|
84
|
+
# Rééchantillonneur rationnel L/M : linéarité, retard de M échantillons en entrée = retard de L en sortie,
|
|
85
|
+
# longueur ceil(N L / M), gain DC, et rejet du repliement quand on décime
|
|
86
|
+
dp.resampler(2, 3)(lambda x: scipy.signal.resample_poly(x, 2, 3))
|
|
87
|
+
|
|
88
|
+
# Traitement par blocs : tout découpage (blocs vides et d'un échantillon compris) doit donner la même sortie
|
|
89
|
+
@dp.streaming()
|
|
90
|
+
class MonFiltre:
|
|
91
|
+
def process(self, bloc): ...
|
|
92
|
+
|
|
93
|
+
# Fonction à état explicite, par exemple lfilter avec zi
|
|
94
|
+
dp.stateful(lambda bloc, z: scipy.signal.lfilter(b, a, bloc, zi=z), init_state=np.zeros(2))
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## En ligne de commande
|
|
98
|
+
|
|
99
|
+
`dsp-props check` trouve tous les opérateurs déclarés dans un module ou un fichier et les vérifie :
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
$ dsp-props check examples/filters.py --examples 100
|
|
103
|
+
FAIL examples/filters.py:buggy_fft (4 relations, 3.4s)
|
|
104
|
+
FAIL inversibility
|
|
105
|
+
inversibility: L2 error 2.000e+00 exceeds tolerance 6.280e-15 (N=2, ref norm 1.414e+00)
|
|
106
|
+
minimal input: [1, 1]
|
|
107
|
+
ok examples/filters.py:fft (6 relations, 6.5s)
|
|
108
|
+
ok examples/filters.py:lowpass (4 relations, 3.1s)
|
|
109
|
+
ok examples/filters.py:resample_2_3 (5 relations, 2.8s)
|
|
110
|
+
|
|
111
|
+
3/4 operators pass
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Cibles possibles : `paquet.module`, `fichier.py`, `module:operateur`. Options : `--json`, `--markdown`, `--html`, `--examples N`, `--derandomize` (entrées reproductibles en CI). Code de sortie 1 si une relation échoue, ce qui suffit pour bloquer une CI.
|
|
115
|
+
|
|
116
|
+
## Code C
|
|
117
|
+
|
|
118
|
+
`dsp_props.adapters` charge une fonction C (bibliothèque partagée, via ctypes) et la déclare comme n'importe quel opérateur. Convention : `void f(const double *x, size_t n, double *y, double *etat)`.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from dsp_props.adapters import build_shared, c_function, c_block_processor
|
|
122
|
+
|
|
123
|
+
lib = build_shared("examples/c/biquad.c")
|
|
124
|
+
dp.verify(dp.lti_filter(transfer=(b, a))(c_function(lib, "biquad", state_size=2)))
|
|
125
|
+
dp.verify(dp.streaming()(c_block_processor(lib, "biquad", state_size=2)))
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`examples/c/biquad.c` contient un biquad correct et deux variantes boguées. Celle qui oublie une variable d'état entre deux blocs passe tous les tests sur un appel unique et n'est détectée que par l'invariance au découpage en blocs.
|
|
129
|
+
|
|
130
|
+
## Tolérances calibrées
|
|
131
|
+
|
|
132
|
+
Une borne fixe en ε · log N ne décrit pas toutes les bibliothèques : sur N = 2…1200, la FFT de PyTorch (MKL) atteint 195 ε contre 4 ε pour NumPy, selon la factorisation de N. `dp.calibrate` mesure l'erreur réelle contre une référence en précision étendue et en déduit la tolérance :
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
tol = dp.calibrate(torch_fft, lengths=range(1, 513)) # refuse un opérateur faux
|
|
136
|
+
print(tol.summary())
|
|
137
|
+
dp.transform(inverse=torch_ifft, parseval=True, tolerance=tol)(torch_fft)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`python benchmarks/error_profile.py` produit le profil d'erreur complet (`benchmarks/error_profile.csv`), et `python benchmarks/plot_error_profile.py` la figure ci-dessous.
|
|
141
|
+
|
|
142
|
+

|
|
143
|
+
|
|
144
|
+
## Valider l'outil : la suite de mutants
|
|
145
|
+
|
|
146
|
+
`mutants/` contient 31 implémentations volontairement boguées, réparties en six familles :
|
|
147
|
+
|
|
148
|
+
| Famille | Mutants |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| FFT complexe | 1/N oublié, conjugaison manquante, décalage d'un indice, normalisation erronée, calcul en float32, signe des twiddles, dernier échantillon ignoré, offset constant |
|
|
151
|
+
| FFT réelle (`rfft`) | un bin de moins, inverse qui ignore N, bin de Nyquist divisé par 2, spectre conjugué, inverse sans symétrie hermitienne |
|
|
152
|
+
| DCT-II orthonormée | poids du coefficient DC oublié, inverse du mauvais type, calcul en float32 |
|
|
153
|
+
| Filtre IIR (ordre 4, résonant) | récursion en float32, filtfilt déclaré causal, coefficients qui changent en cours de route, état initial non nul, gain faux de 1 % |
|
|
154
|
+
| Rééchantillonneur 2/3 | gain d'interpolation oublié, anti-repliement trop faible, longueur arrondie par défaut, phase qui dépend de la parité de N, filtrage en float32, état global qui fuit d'un appel à l'autre |
|
|
155
|
+
| Streaming par blocs | état remis à zéro, historique décalé d'un échantillon, bloc vide qui efface l'état, blocs courts non filtrés |
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
python mutants/run_mutants.py
|
|
159
|
+
...
|
|
160
|
+
Mutation score: 31/31 (100%)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Les contre-exemples sont minuscules : pour 27 mutants sur 30 (hors `iir_wrong_gain`, détecté sur la réponse impulsionnelle), le signal réduit par Hypothesis fait au plus 3 échantillons (médiane : 2), et 27 mutants tombent dans les 3 premières entrées générées. Les exceptions sont attendues : 97 échantillons pour un filtre qui change après 64, et 192 pour les relations de gain DC et de repliement, qui exigent des signaux longs. `python mutants/run_mutants.py --json benchmarks/mutant_results.json` enregistre ces mesures.
|
|
164
|
+
|
|
165
|
+
Chaque rapport donne l'entrée minimale et s'exporte :
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
report = dp.verify(ma_fft, raise_on_failure=False)
|
|
169
|
+
print(report) # FAIL inversibility ... minimal input: [1, 1]
|
|
170
|
+
report.to_json() # pour la CI ou un tableau de résultats
|
|
171
|
+
report.to_markdown() # pour une PR ou un ticket
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Angle mort documenté : un filtre faux mais toujours linéaire, invariant et causal (gain multiplié par 1,01) passe toutes les relations si l'on ne déclare pas sa fonction de transfert. Avec `transfer=(b, a)`, il est détecté.
|
|
175
|
+
|
|
176
|
+
## Chasse aux bugs dans de vraies bibliothèques
|
|
177
|
+
|
|
178
|
+
`benchmarks/campaign.py` applique les relations à SciPy, NumPy, PyTorch, librosa et torchaudio (46 cibles).
|
|
179
|
+
Premier bug signalé : [scipy/scipy#26406](https://github.com/scipy/scipy/issues/26406). Résultats détaillés dans `benchmarks/RESULTS-*.md`.
|
|
180
|
+
|
|
181
|
+
## Installation
|
|
182
|
+
|
|
183
|
+
Une fois publié sur PyPI :
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
python -m pip install "dsp-props[scipy]" # [scipy] pour les filtres IIR et les fonctions de transfert
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
En développement :
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
git clone https://github.com/clement-basdevant/dsp-props
|
|
193
|
+
cd dsp-props
|
|
194
|
+
python -m pip install -e ".[dev]"
|
|
195
|
+
python -m pytest
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Organisation
|
|
199
|
+
|
|
200
|
+
| Dossier | Contenu |
|
|
201
|
+
| --- | --- |
|
|
202
|
+
| `src/dsp_props/core/` | Modèles de tolérance, comparaison robuste |
|
|
203
|
+
| `src/dsp_props/generators/` | Stratégies Hypothesis pour signaux |
|
|
204
|
+
| `src/dsp_props/relations/` | Une relation métamorphique = une classe |
|
|
205
|
+
| `mutants/` | Implémentations boguées et calcul du score de mutation |
|
|
206
|
+
| `tests/` | Tests de l'outil lui-même |
|
|
207
|
+
| `docs/` | Schéma directeur et documentation |
|
|
208
|
+
| `papers/` | Bibliographie et brouillons d'articles |
|
|
209
|
+
|
|
210
|
+
## Feuille de route
|
|
211
|
+
|
|
212
|
+
1. **Fondations** : générateurs, relations de base, tests sur `numpy.fft` *(fait)*
|
|
213
|
+
2. **Moteur de tolérance** : modèles d'erreur par classe d'opérateur, calibration contre précision étendue *(fait)*
|
|
214
|
+
3. **Relations avancées** : causalité, stabilité, streaming par blocs, multicadence *(fait)*
|
|
215
|
+
4. **Suite de mutants** : une quarantaine de mutants, essai sur `scipy.signal` *(31 mutants, campagne sur 46 cibles réelles ; à compléter)*
|
|
216
|
+
5. **Shrinking et rapport** : contre-exemples minimaux, rapport HTML *(fait)*
|
|
217
|
+
6. **Auto-découverte et adaptateur C** *(fait : `dsp-props check`, `dsp_props.adapters`)*
|
|
218
|
+
|
|
219
|
+
## Licence
|
|
220
|
+
|
|
221
|
+
BSD-3-Clause, comme NumPy et SciPy.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# dsp-props
|
|
2
|
+
|
|
3
|
+
**Tests de propriété pour le traitement du signal** · *Property-based testing for DSP code*
|
|
4
|
+
|
|
5
|
+
Le code DSP est souvent testé en comparant sa sortie à une référence (MATLAB, SciPy) sur quelques signaux, avec une tolérance choisie à la main. `dsp-props` vérifie plutôt des **relations qui doivent toujours tenir** (linéarité, théorème du décalage, Parseval, symétrie hermitienne, inversibilité, théorème de convolution), sur des **signaux générés automatiquement** par [Hypothesis](https://hypothesis.works/) et choisis pour piéger le code : impulsions, sinusoïdes hors grille, tons proches, quasi-Nyquist, amplitudes extrêmes, longueurs 1, premières ou 2^k ± 1. Les tolérances dépendent de l'opération (erreur en O(ε log N) pour une FFT) au lieu d'un `atol` fixe.
|
|
6
|
+
|
|
7
|
+
> Statut : pré-alpha, phase 1 du [schéma directeur](docs/schema-directeur.md).
|
|
8
|
+
|
|
9
|
+
## Exemple
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import numpy as np
|
|
13
|
+
import dsp_props as dp
|
|
14
|
+
|
|
15
|
+
@dp.transform(inverse=np.fft.ifft, parseval=True, real_input_hermitian=True)
|
|
16
|
+
def my_fft(x):
|
|
17
|
+
return np.fft.fft(x)
|
|
18
|
+
|
|
19
|
+
print(dp.verify(my_fft))
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
dsp-props report for my_fft
|
|
24
|
+
ok linearity
|
|
25
|
+
ok circular shift
|
|
26
|
+
ok parseval
|
|
27
|
+
ok hermitian symmetry
|
|
28
|
+
ok inversibility
|
|
29
|
+
ok convolution theorem
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
En cas d'échec, `verify` lève `PropertyViolation` avec, pour chaque relation violée, le contre-exemple réduit par Hypothesis.
|
|
33
|
+
|
|
34
|
+
Autres déclarations :
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
# FFT réelle : l'inverse reçoit la longueur N
|
|
38
|
+
dp.transform(inverse=np.fft.irfft, kind="rdft", parseval=True)(np.fft.rfft)
|
|
39
|
+
|
|
40
|
+
# Transformée linéaire quelconque (DCT, ondelettes...)
|
|
41
|
+
dp.transform(kind="generic", inverse=idct_ortho, parseval=True, parseval_scale=lambda n: 1.0)(dct_ortho)
|
|
42
|
+
|
|
43
|
+
# Filtre LTI : linéarité, invariance temporelle, causalité, réponse en fréquence = B(z)/A(z).
|
|
44
|
+
# La tolérance suit le conditionnement de la récursion (IIRTolerance).
|
|
45
|
+
dp.lti_filter(causal=True, transfer=(b, a))(lambda x: scipy.signal.lfilter(b, a, x))
|
|
46
|
+
|
|
47
|
+
# Stabilité EBSB : aucune entrée bornée par 1 ne doit dépasser le gain déclaré (entrée pire cas construite)
|
|
48
|
+
dp.lti_filter(max_gain=l1_de_h)(mon_filtre)
|
|
49
|
+
|
|
50
|
+
# Rééchantillonneur rationnel L/M : linéarité, retard de M échantillons en entrée = retard de L en sortie,
|
|
51
|
+
# longueur ceil(N L / M), gain DC, et rejet du repliement quand on décime
|
|
52
|
+
dp.resampler(2, 3)(lambda x: scipy.signal.resample_poly(x, 2, 3))
|
|
53
|
+
|
|
54
|
+
# Traitement par blocs : tout découpage (blocs vides et d'un échantillon compris) doit donner la même sortie
|
|
55
|
+
@dp.streaming()
|
|
56
|
+
class MonFiltre:
|
|
57
|
+
def process(self, bloc): ...
|
|
58
|
+
|
|
59
|
+
# Fonction à état explicite, par exemple lfilter avec zi
|
|
60
|
+
dp.stateful(lambda bloc, z: scipy.signal.lfilter(b, a, bloc, zi=z), init_state=np.zeros(2))
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## En ligne de commande
|
|
64
|
+
|
|
65
|
+
`dsp-props check` trouve tous les opérateurs déclarés dans un module ou un fichier et les vérifie :
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
$ dsp-props check examples/filters.py --examples 100
|
|
69
|
+
FAIL examples/filters.py:buggy_fft (4 relations, 3.4s)
|
|
70
|
+
FAIL inversibility
|
|
71
|
+
inversibility: L2 error 2.000e+00 exceeds tolerance 6.280e-15 (N=2, ref norm 1.414e+00)
|
|
72
|
+
minimal input: [1, 1]
|
|
73
|
+
ok examples/filters.py:fft (6 relations, 6.5s)
|
|
74
|
+
ok examples/filters.py:lowpass (4 relations, 3.1s)
|
|
75
|
+
ok examples/filters.py:resample_2_3 (5 relations, 2.8s)
|
|
76
|
+
|
|
77
|
+
3/4 operators pass
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Cibles possibles : `paquet.module`, `fichier.py`, `module:operateur`. Options : `--json`, `--markdown`, `--html`, `--examples N`, `--derandomize` (entrées reproductibles en CI). Code de sortie 1 si une relation échoue, ce qui suffit pour bloquer une CI.
|
|
81
|
+
|
|
82
|
+
## Code C
|
|
83
|
+
|
|
84
|
+
`dsp_props.adapters` charge une fonction C (bibliothèque partagée, via ctypes) et la déclare comme n'importe quel opérateur. Convention : `void f(const double *x, size_t n, double *y, double *etat)`.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from dsp_props.adapters import build_shared, c_function, c_block_processor
|
|
88
|
+
|
|
89
|
+
lib = build_shared("examples/c/biquad.c")
|
|
90
|
+
dp.verify(dp.lti_filter(transfer=(b, a))(c_function(lib, "biquad", state_size=2)))
|
|
91
|
+
dp.verify(dp.streaming()(c_block_processor(lib, "biquad", state_size=2)))
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`examples/c/biquad.c` contient un biquad correct et deux variantes boguées. Celle qui oublie une variable d'état entre deux blocs passe tous les tests sur un appel unique et n'est détectée que par l'invariance au découpage en blocs.
|
|
95
|
+
|
|
96
|
+
## Tolérances calibrées
|
|
97
|
+
|
|
98
|
+
Une borne fixe en ε · log N ne décrit pas toutes les bibliothèques : sur N = 2…1200, la FFT de PyTorch (MKL) atteint 195 ε contre 4 ε pour NumPy, selon la factorisation de N. `dp.calibrate` mesure l'erreur réelle contre une référence en précision étendue et en déduit la tolérance :
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
tol = dp.calibrate(torch_fft, lengths=range(1, 513)) # refuse un opérateur faux
|
|
102
|
+
print(tol.summary())
|
|
103
|
+
dp.transform(inverse=torch_ifft, parseval=True, tolerance=tol)(torch_fft)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`python benchmarks/error_profile.py` produit le profil d'erreur complet (`benchmarks/error_profile.csv`), et `python benchmarks/plot_error_profile.py` la figure ci-dessous.
|
|
107
|
+
|
|
108
|
+

|
|
109
|
+
|
|
110
|
+
## Valider l'outil : la suite de mutants
|
|
111
|
+
|
|
112
|
+
`mutants/` contient 31 implémentations volontairement boguées, réparties en six familles :
|
|
113
|
+
|
|
114
|
+
| Famille | Mutants |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| FFT complexe | 1/N oublié, conjugaison manquante, décalage d'un indice, normalisation erronée, calcul en float32, signe des twiddles, dernier échantillon ignoré, offset constant |
|
|
117
|
+
| FFT réelle (`rfft`) | un bin de moins, inverse qui ignore N, bin de Nyquist divisé par 2, spectre conjugué, inverse sans symétrie hermitienne |
|
|
118
|
+
| DCT-II orthonormée | poids du coefficient DC oublié, inverse du mauvais type, calcul en float32 |
|
|
119
|
+
| Filtre IIR (ordre 4, résonant) | récursion en float32, filtfilt déclaré causal, coefficients qui changent en cours de route, état initial non nul, gain faux de 1 % |
|
|
120
|
+
| Rééchantillonneur 2/3 | gain d'interpolation oublié, anti-repliement trop faible, longueur arrondie par défaut, phase qui dépend de la parité de N, filtrage en float32, état global qui fuit d'un appel à l'autre |
|
|
121
|
+
| Streaming par blocs | état remis à zéro, historique décalé d'un échantillon, bloc vide qui efface l'état, blocs courts non filtrés |
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
python mutants/run_mutants.py
|
|
125
|
+
...
|
|
126
|
+
Mutation score: 31/31 (100%)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Les contre-exemples sont minuscules : pour 27 mutants sur 30 (hors `iir_wrong_gain`, détecté sur la réponse impulsionnelle), le signal réduit par Hypothesis fait au plus 3 échantillons (médiane : 2), et 27 mutants tombent dans les 3 premières entrées générées. Les exceptions sont attendues : 97 échantillons pour un filtre qui change après 64, et 192 pour les relations de gain DC et de repliement, qui exigent des signaux longs. `python mutants/run_mutants.py --json benchmarks/mutant_results.json` enregistre ces mesures.
|
|
130
|
+
|
|
131
|
+
Chaque rapport donne l'entrée minimale et s'exporte :
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
report = dp.verify(ma_fft, raise_on_failure=False)
|
|
135
|
+
print(report) # FAIL inversibility ... minimal input: [1, 1]
|
|
136
|
+
report.to_json() # pour la CI ou un tableau de résultats
|
|
137
|
+
report.to_markdown() # pour une PR ou un ticket
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Angle mort documenté : un filtre faux mais toujours linéaire, invariant et causal (gain multiplié par 1,01) passe toutes les relations si l'on ne déclare pas sa fonction de transfert. Avec `transfer=(b, a)`, il est détecté.
|
|
141
|
+
|
|
142
|
+
## Chasse aux bugs dans de vraies bibliothèques
|
|
143
|
+
|
|
144
|
+
`benchmarks/campaign.py` applique les relations à SciPy, NumPy, PyTorch, librosa et torchaudio (46 cibles).
|
|
145
|
+
Premier bug signalé : [scipy/scipy#26406](https://github.com/scipy/scipy/issues/26406). Résultats détaillés dans `benchmarks/RESULTS-*.md`.
|
|
146
|
+
|
|
147
|
+
## Installation
|
|
148
|
+
|
|
149
|
+
Une fois publié sur PyPI :
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
python -m pip install "dsp-props[scipy]" # [scipy] pour les filtres IIR et les fonctions de transfert
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
En développement :
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
git clone https://github.com/clement-basdevant/dsp-props
|
|
159
|
+
cd dsp-props
|
|
160
|
+
python -m pip install -e ".[dev]"
|
|
161
|
+
python -m pytest
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Organisation
|
|
165
|
+
|
|
166
|
+
| Dossier | Contenu |
|
|
167
|
+
| --- | --- |
|
|
168
|
+
| `src/dsp_props/core/` | Modèles de tolérance, comparaison robuste |
|
|
169
|
+
| `src/dsp_props/generators/` | Stratégies Hypothesis pour signaux |
|
|
170
|
+
| `src/dsp_props/relations/` | Une relation métamorphique = une classe |
|
|
171
|
+
| `mutants/` | Implémentations boguées et calcul du score de mutation |
|
|
172
|
+
| `tests/` | Tests de l'outil lui-même |
|
|
173
|
+
| `docs/` | Schéma directeur et documentation |
|
|
174
|
+
| `papers/` | Bibliographie et brouillons d'articles |
|
|
175
|
+
|
|
176
|
+
## Feuille de route
|
|
177
|
+
|
|
178
|
+
1. **Fondations** : générateurs, relations de base, tests sur `numpy.fft` *(fait)*
|
|
179
|
+
2. **Moteur de tolérance** : modèles d'erreur par classe d'opérateur, calibration contre précision étendue *(fait)*
|
|
180
|
+
3. **Relations avancées** : causalité, stabilité, streaming par blocs, multicadence *(fait)*
|
|
181
|
+
4. **Suite de mutants** : une quarantaine de mutants, essai sur `scipy.signal` *(31 mutants, campagne sur 46 cibles réelles ; à compléter)*
|
|
182
|
+
5. **Shrinking et rapport** : contre-exemples minimaux, rapport HTML *(fait)*
|
|
183
|
+
6. **Auto-découverte et adaptateur C** *(fait : `dsp-props check`, `dsp_props.adapters`)*
|
|
184
|
+
|
|
185
|
+
## Licence
|
|
186
|
+
|
|
187
|
+
BSD-3-Clause, comme NumPy et SciPy.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/* Biquad low-pass (Butterworth, fc = 0.2 x Nyquist), transposed direct form II.
|
|
2
|
+
*
|
|
3
|
+
* Calling convention understood by dsp_props.adapters:
|
|
4
|
+
* void f(const double *x, size_t n, double *y, double *state)
|
|
5
|
+
* The state (2 doubles) carries the filter memory from one block to the next.
|
|
6
|
+
*/
|
|
7
|
+
#include <stddef.h>
|
|
8
|
+
|
|
9
|
+
static const double B[3] = {0.06745527388907189, 0.13491054777814378, 0.06745527388907189};
|
|
10
|
+
static const double A[3] = {1.0, -1.1429805025399011, 0.41280159809618877};
|
|
11
|
+
|
|
12
|
+
void biquad(const double *x, size_t n, double *y, double *s)
|
|
13
|
+
{
|
|
14
|
+
for (size_t i = 0; i < n; i++) {
|
|
15
|
+
double out = B[0] * x[i] + s[0];
|
|
16
|
+
s[0] = B[1] * x[i] - A[1] * out + s[1];
|
|
17
|
+
s[1] = B[2] * x[i] - A[2] * out;
|
|
18
|
+
y[i] = out;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Bug: the accumulator is single precision. */
|
|
23
|
+
void biquad_float_acc(const double *x, size_t n, double *y, double *s)
|
|
24
|
+
{
|
|
25
|
+
for (size_t i = 0; i < n; i++) {
|
|
26
|
+
float out = (float)(B[0] * x[i] + s[0]);
|
|
27
|
+
s[0] = B[1] * x[i] - A[1] * out + s[1];
|
|
28
|
+
s[1] = B[2] * x[i] - A[2] * out;
|
|
29
|
+
y[i] = out;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* Bug: the second state variable is not carried across blocks (local copy). */
|
|
34
|
+
void biquad_drops_state(const double *x, size_t n, double *y, double *s)
|
|
35
|
+
{
|
|
36
|
+
double s1 = 0.0;
|
|
37
|
+
for (size_t i = 0; i < n; i++) {
|
|
38
|
+
double out = B[0] * x[i] + s[0];
|
|
39
|
+
s[0] = B[1] * x[i] - A[1] * out + s1;
|
|
40
|
+
s1 = B[2] * x[i] - A[2] * out;
|
|
41
|
+
y[i] = out;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Example operators for `dsp-props check examples/filters.py`."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from scipy import signal
|
|
5
|
+
|
|
6
|
+
import dsp_props as dp
|
|
7
|
+
|
|
8
|
+
B, A = signal.butter(4, 0.2)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dp.transform(inverse=np.fft.ifft, parseval=True, real_input_hermitian=True)
|
|
12
|
+
def fft(x):
|
|
13
|
+
return np.fft.fft(x)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dp.lti_filter(transfer=(B, A))
|
|
17
|
+
def lowpass(x):
|
|
18
|
+
return signal.lfilter(B, A, x)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dp.resampler(2, 3)
|
|
22
|
+
def resample_2_3(x):
|
|
23
|
+
return signal.resample_poly(x, 2, 3)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dp.transform(inverse=np.fft.ifft)
|
|
27
|
+
def buggy_fft(x):
|
|
28
|
+
"""Off by one bin: dsp-props reports the smallest input that shows it."""
|
|
29
|
+
return np.roll(np.fft.fft(x), 1)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dsp-props"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Property-based testing for digital signal processing code"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "BSD-3-Clause"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Clément Basdevant" }]
|
|
14
|
+
keywords = ["signal processing", "DSP", "property-based testing", "metamorphic testing", "hypothesis", "FFT", "filters"]
|
|
15
|
+
dependencies = ["numpy>=1.24", "hypothesis>=6.80"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Framework :: Hypothesis",
|
|
19
|
+
"Framework :: Pytest",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Scientific/Engineering",
|
|
27
|
+
"Topic :: Software Development :: Testing",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/clement-basdevant/dsp-props"
|
|
32
|
+
Source = "https://github.com/clement-basdevant/dsp-props"
|
|
33
|
+
Issues = "https://github.com/clement-basdevant/dsp-props/issues"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
dsp-props = "dsp_props.cli:main"
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
# transfer-function checks, IIR error model and the examples use scipy.signal
|
|
40
|
+
scipy = ["scipy>=1.11"]
|
|
41
|
+
dev = ["pytest>=7", "scipy>=1.11", "build", "twine"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.version]
|
|
44
|
+
path = "src/dsp_props/__init__.py"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
include = ["src", "tests", "examples", "README.md", "LICENSE", "CITATION.cff", "pyproject.toml"]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/dsp_props"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
pythonpath = ["src", "."]
|
|
@@ -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
|
+
]
|