waveforms 3.4.0__tar.gz → 3.5.1__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.
- {waveforms-3.4.0/waveforms.egg-info → waveforms-3.5.1}/PKG-INFO +66 -1
- {waveforms-3.4.0 → waveforms-3.5.1}/README.md +65 -0
- waveforms-3.5.1/tests/test_native_sos.py +176 -0
- waveforms-3.5.1/tests/test_nonlinear.py +255 -0
- waveforms-3.5.1/tests/test_output_limits.py +252 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/tests/test_waveform.py +1 -1
- waveforms-3.5.1/waveforms/WaveformLexer.py +134 -0
- waveforms-3.5.1/waveforms/WaveformListener.py +228 -0
- waveforms-3.5.1/waveforms/WaveformParser.py +1241 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/__init__.py +2 -1
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/_cwaveform.c +970 -19
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/_cwaveform.h +76 -1
- waveforms-3.5.1/waveforms/_cwaveform.md +157 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/_waveform.pyi +35 -1
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/_waveform.pyx +314 -2
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/distortion.py +166 -10
- waveforms-3.5.1/waveforms/nonlinear.py +353 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/version.py +1 -1
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/waveform.py +283 -87
- {waveforms-3.4.0 → waveforms-3.5.1/waveforms.egg-info}/PKG-INFO +66 -1
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms.egg-info/SOURCES.txt +7 -0
- waveforms-3.4.0/waveforms/_cwaveform.md +0 -85
- {waveforms-3.4.0 → waveforms-3.5.1}/LICENSE +0 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/MANIFEST.in +0 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/pyproject.toml +0 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/setup.cfg +0 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/setup.py +0 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/tests/test_core.py +0 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/tests/test_wavevstack.py +0 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/Waveform.g4 +0 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/__main__.py +0 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/utils.py +0 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/waveform_parser.py +0 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms.egg-info/dependency_links.txt +0 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms.egg-info/entry_points.txt +0 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms.egg-info/requires.txt +0 -0
- {waveforms-3.4.0 → waveforms-3.5.1}/waveforms.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: waveforms
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.5.1
|
|
4
4
|
Summary: Edit waveforms used in experiment
|
|
5
5
|
Author-email: feihoo87 <feihoo87@gmail.com>
|
|
6
6
|
Maintainer-email: feihoo87 <feihoo87@gmail.com>
|
|
@@ -153,6 +153,71 @@ Real waveforms and stacks therefore avoid complex storage and arithmetic for
|
|
|
153
153
|
the common real-valued case. `ComplexWaveform.real` and `.imag` expose the two
|
|
154
154
|
real channel waveforms.
|
|
155
155
|
|
|
156
|
+
### Post-sampling nonlinear calibration
|
|
157
|
+
|
|
158
|
+
`NonlinearMap` compiles one monotone branch of calibration samples to a compact
|
|
159
|
+
native lookup table. A centered map is useful when a waveform describes a
|
|
160
|
+
frequency excursion around an idle point:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
frequency = np.array([4.0e9, 4.5e9, 5.0e9, 5.5e9, 6.0e9])
|
|
164
|
+
flux = np.array([0.31, 0.22, 0.08, -0.06, -0.16])
|
|
165
|
+
|
|
166
|
+
frequency_to_flux = wf.NonlinearMap.from_samples(
|
|
167
|
+
frequency,
|
|
168
|
+
flux,
|
|
169
|
+
method="monotone_cubic", # PCHIP compiled to uniform cubic segments
|
|
170
|
+
reference=5.0e9, # map(0) == 0 around the idle frequency
|
|
171
|
+
dtype=np.float32,
|
|
172
|
+
extrapolate="error",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
trajectory.start = 0
|
|
176
|
+
trajectory.stop = 200e-9
|
|
177
|
+
trajectory.sample_rate = 2_400_000_000
|
|
178
|
+
trajectory.nonlinear = frequency_to_flux
|
|
179
|
+
flux_samples = trajectory.sample(dtype=np.int16)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The sampling order is waveform accumulation, nonlinear mapping, optional SOS
|
|
183
|
+
filtering/predistortion, output amplitude limits (`min`/`max`), and finally
|
|
184
|
+
integer quantization or floating-point output. This is important for
|
|
185
|
+
`WaveVStack`: the map is applied to the accumulated trajectory rather than to
|
|
186
|
+
each pulse event independently. `method="linear"` selects the smaller and
|
|
187
|
+
fastest two-point interpolation path. Maps serialize independently through
|
|
188
|
+
`to_bytes()`/`from_bytes()` using the language-neutral `NLM1` format.
|
|
189
|
+
|
|
190
|
+
Every waveform and stack has output limits, defaulting to `min=-np.inf` and
|
|
191
|
+
`max=np.inf`. A stack ignores its child waveforms' limits and uses only its own:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
stack.min = -0.2
|
|
195
|
+
stack.max = 0.3
|
|
196
|
+
samples = stack.sample() # limit after the complete calibration/filter chain
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Limits apply to whole and chunked sampling, including `out=` buffers and
|
|
200
|
+
integer output. Complex signals limit I and Q independently; `sample_iq()`
|
|
201
|
+
uses the same final limits. The filter state continues from the unclipped
|
|
202
|
+
filtered signal across chunks. DAC `full_scale` controls integer conversion
|
|
203
|
+
and saturation separately from these amplitude limits.
|
|
204
|
+
|
|
205
|
+
Real-coefficient SOS filters (`filters=(sos, initial)`) run in the C core for
|
|
206
|
+
float64 and complex128 signals. This stage subtracts the baseline `initial`,
|
|
207
|
+
applies the cascade, restores the baseline, and applies the final limits.
|
|
208
|
+
Real int16/int32 output is quantized in the same stage, without allocating a
|
|
209
|
+
full filtered floating-point buffer. Float output can reuse the sampled
|
|
210
|
+
buffer. A small fixed scratch buffer keeps filtering and conversion local;
|
|
211
|
+
waveform evaluation and nonlinear mapping still precede this stage. Complex
|
|
212
|
+
coefficients and extended precision retain the SciPy implementation.
|
|
213
|
+
`sample_iq()` still converts the filtered complex result into separate I/Q
|
|
214
|
+
output arrays. See [the SOS benchmark](benchmarks/sos_filter.md).
|
|
215
|
+
|
|
216
|
+
Calling `waveform(t)` directly evaluates with amplitude limits but without
|
|
217
|
+
nonlinear calibration or filtering. Sampling evaluates the underlying signal
|
|
218
|
+
without limits before applying its processing chain. Limits are sampling
|
|
219
|
+
metadata preserved by pickle, not part of the raw WNF4/WNS4 binary blocks.
|
|
220
|
+
|
|
156
221
|
## Reporting Issues
|
|
157
222
|
Please report all issues [on github](https://github.com/feihoo87/waveforms/issues).
|
|
158
223
|
|
|
@@ -109,6 +109,71 @@ Real waveforms and stacks therefore avoid complex storage and arithmetic for
|
|
|
109
109
|
the common real-valued case. `ComplexWaveform.real` and `.imag` expose the two
|
|
110
110
|
real channel waveforms.
|
|
111
111
|
|
|
112
|
+
### Post-sampling nonlinear calibration
|
|
113
|
+
|
|
114
|
+
`NonlinearMap` compiles one monotone branch of calibration samples to a compact
|
|
115
|
+
native lookup table. A centered map is useful when a waveform describes a
|
|
116
|
+
frequency excursion around an idle point:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
frequency = np.array([4.0e9, 4.5e9, 5.0e9, 5.5e9, 6.0e9])
|
|
120
|
+
flux = np.array([0.31, 0.22, 0.08, -0.06, -0.16])
|
|
121
|
+
|
|
122
|
+
frequency_to_flux = wf.NonlinearMap.from_samples(
|
|
123
|
+
frequency,
|
|
124
|
+
flux,
|
|
125
|
+
method="monotone_cubic", # PCHIP compiled to uniform cubic segments
|
|
126
|
+
reference=5.0e9, # map(0) == 0 around the idle frequency
|
|
127
|
+
dtype=np.float32,
|
|
128
|
+
extrapolate="error",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
trajectory.start = 0
|
|
132
|
+
trajectory.stop = 200e-9
|
|
133
|
+
trajectory.sample_rate = 2_400_000_000
|
|
134
|
+
trajectory.nonlinear = frequency_to_flux
|
|
135
|
+
flux_samples = trajectory.sample(dtype=np.int16)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The sampling order is waveform accumulation, nonlinear mapping, optional SOS
|
|
139
|
+
filtering/predistortion, output amplitude limits (`min`/`max`), and finally
|
|
140
|
+
integer quantization or floating-point output. This is important for
|
|
141
|
+
`WaveVStack`: the map is applied to the accumulated trajectory rather than to
|
|
142
|
+
each pulse event independently. `method="linear"` selects the smaller and
|
|
143
|
+
fastest two-point interpolation path. Maps serialize independently through
|
|
144
|
+
`to_bytes()`/`from_bytes()` using the language-neutral `NLM1` format.
|
|
145
|
+
|
|
146
|
+
Every waveform and stack has output limits, defaulting to `min=-np.inf` and
|
|
147
|
+
`max=np.inf`. A stack ignores its child waveforms' limits and uses only its own:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
stack.min = -0.2
|
|
151
|
+
stack.max = 0.3
|
|
152
|
+
samples = stack.sample() # limit after the complete calibration/filter chain
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Limits apply to whole and chunked sampling, including `out=` buffers and
|
|
156
|
+
integer output. Complex signals limit I and Q independently; `sample_iq()`
|
|
157
|
+
uses the same final limits. The filter state continues from the unclipped
|
|
158
|
+
filtered signal across chunks. DAC `full_scale` controls integer conversion
|
|
159
|
+
and saturation separately from these amplitude limits.
|
|
160
|
+
|
|
161
|
+
Real-coefficient SOS filters (`filters=(sos, initial)`) run in the C core for
|
|
162
|
+
float64 and complex128 signals. This stage subtracts the baseline `initial`,
|
|
163
|
+
applies the cascade, restores the baseline, and applies the final limits.
|
|
164
|
+
Real int16/int32 output is quantized in the same stage, without allocating a
|
|
165
|
+
full filtered floating-point buffer. Float output can reuse the sampled
|
|
166
|
+
buffer. A small fixed scratch buffer keeps filtering and conversion local;
|
|
167
|
+
waveform evaluation and nonlinear mapping still precede this stage. Complex
|
|
168
|
+
coefficients and extended precision retain the SciPy implementation.
|
|
169
|
+
`sample_iq()` still converts the filtered complex result into separate I/Q
|
|
170
|
+
output arrays. See [the SOS benchmark](benchmarks/sos_filter.md).
|
|
171
|
+
|
|
172
|
+
Calling `waveform(t)` directly evaluates with amplitude limits but without
|
|
173
|
+
nonlinear calibration or filtering. Sampling evaluates the underlying signal
|
|
174
|
+
without limits before applying its processing chain. Limits are sampling
|
|
175
|
+
metadata preserved by pickle, not part of the raw WNF4/WNS4 binary blocks.
|
|
176
|
+
|
|
112
177
|
## Reporting Issues
|
|
113
178
|
Please report all issues [on github](https://github.com/feihoo87/waveforms/issues).
|
|
114
179
|
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Native SOS processing agrees with SciPy and keeps output limits out of state."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
from scipy.signal import butter, sosfilt
|
|
6
|
+
|
|
7
|
+
import waveforms as wf
|
|
8
|
+
from waveforms._waveform import quantize_samples, sosfilt_samples
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _clip(values, lower, upper):
|
|
12
|
+
if np.iscomplexobj(values):
|
|
13
|
+
return (np.clip(values.real, lower, upper)
|
|
14
|
+
+ 1j * np.clip(values.imag, lower, upper))
|
|
15
|
+
return np.clip(values, lower, upper)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.parametrize("sections", [1, 2, 4, 8, 16])
|
|
19
|
+
@pytest.mark.parametrize("complex_signal", [False, True])
|
|
20
|
+
def test_scipy_equivalence_and_streaming(sections, complex_signal):
|
|
21
|
+
rng = np.random.default_rng(832)
|
|
22
|
+
sos = butter(2 * sections, .2, output="sos")
|
|
23
|
+
values = rng.normal(size=1031)
|
|
24
|
+
zi = rng.normal(size=(sections, 2)) * .001
|
|
25
|
+
initial = .07
|
|
26
|
+
if complex_signal:
|
|
27
|
+
values = values + 1j * rng.normal(size=len(values))
|
|
28
|
+
zi = zi + 1j * rng.normal(size=zi.shape) * .001
|
|
29
|
+
initial += .04j
|
|
30
|
+
original = values.copy()
|
|
31
|
+
initial_state = zi.copy()
|
|
32
|
+
filtered, expected_state = sosfilt(sos, values - initial, zi=zi)
|
|
33
|
+
expected = _clip(filtered + initial, -.17, .23)
|
|
34
|
+
result, state = sosfilt_samples(values, sos, initial, zi,
|
|
35
|
+
lower=-.17, upper=.23)
|
|
36
|
+
np.testing.assert_allclose(result, expected, rtol=3e-13, atol=3e-14)
|
|
37
|
+
np.testing.assert_allclose(state, expected_state, rtol=3e-13, atol=3e-14)
|
|
38
|
+
np.testing.assert_array_equal(zi, initial_state)
|
|
39
|
+
np.testing.assert_array_equal(values, original)
|
|
40
|
+
|
|
41
|
+
# Boundaries straddle the internal block size and include a one-sample chunk.
|
|
42
|
+
output = np.empty_like(values)
|
|
43
|
+
state = zi
|
|
44
|
+
start = 0
|
|
45
|
+
for stop in (1, 254, 511, 512, 1024, len(values)):
|
|
46
|
+
part, state = sosfilt_samples(values[start:stop], sos, initial, state,
|
|
47
|
+
lower=-.17, upper=.23,
|
|
48
|
+
out=output[start:stop])
|
|
49
|
+
assert np.shares_memory(part, output)
|
|
50
|
+
start = stop
|
|
51
|
+
np.testing.assert_array_equal(output, result)
|
|
52
|
+
np.testing.assert_allclose(state, expected_state, rtol=3e-13, atol=3e-14)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.parametrize("bits", [16, 32])
|
|
56
|
+
@pytest.mark.parametrize("sections", [1, 4, 8])
|
|
57
|
+
def test_direct_quantization_matches_scipy(bits, sections):
|
|
58
|
+
rng = np.random.default_rng(337)
|
|
59
|
+
values = rng.uniform(-2, 2, 5003)
|
|
60
|
+
sos = butter(sections * 2, .25, output="sos")
|
|
61
|
+
expected = quantize_samples(
|
|
62
|
+
np.clip(sosfilt(sos, values - .1) + .1, -.25, .35), bits, .7)
|
|
63
|
+
output = np.empty_like(expected)
|
|
64
|
+
result, _ = sosfilt_samples(values, sos, .1, bits=bits, full_scale=.7,
|
|
65
|
+
lower=-.25, upper=.35, out=output)
|
|
66
|
+
assert result is output
|
|
67
|
+
np.testing.assert_array_equal(result, expected)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.parametrize("complex_signal", [False, True])
|
|
71
|
+
@pytest.mark.parametrize("overlap", [False, True])
|
|
72
|
+
def test_aliasing_and_readonly_inputs(complex_signal, overlap):
|
|
73
|
+
rng = np.random.default_rng(4)
|
|
74
|
+
storage = rng.normal(size=601)
|
|
75
|
+
if complex_signal:
|
|
76
|
+
storage = storage + 1j * rng.normal(size=len(storage))
|
|
77
|
+
values = storage[:-1]
|
|
78
|
+
output = storage[1:] if overlap else values
|
|
79
|
+
sos = butter(6, .3, output="sos")
|
|
80
|
+
sos.flags.writeable = False
|
|
81
|
+
# SciPy 1.13 requires a writable coefficient buffer.
|
|
82
|
+
expected = sosfilt(sos.copy(), values.copy())
|
|
83
|
+
result, _ = sosfilt_samples(values, sos, out=output)
|
|
84
|
+
assert result is output
|
|
85
|
+
np.testing.assert_allclose(result, expected, rtol=3e-13, atol=3e-14)
|
|
86
|
+
values.flags.writeable = False
|
|
87
|
+
expected = sosfilt(sos.copy(), values.copy())
|
|
88
|
+
result, _ = sosfilt_samples(values, sos)
|
|
89
|
+
np.testing.assert_allclose(result, expected, rtol=3e-13, atol=3e-14)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_coefficients_may_overlap_output():
|
|
93
|
+
sos = butter(8, .3, output="sos")
|
|
94
|
+
values = np.linspace(-1, 1, sos.size)
|
|
95
|
+
expected = sosfilt(sos.copy(), values)
|
|
96
|
+
result, _ = sosfilt_samples(values, sos, out=sos.reshape(-1))
|
|
97
|
+
np.testing.assert_allclose(result, expected, rtol=3e-13, atol=3e-14)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.parametrize("dtype", [np.float64, np.complex128])
|
|
101
|
+
def test_empty_input_preserves_state(dtype):
|
|
102
|
+
sos = butter(4, .3, output="sos")
|
|
103
|
+
zi = np.ones((2, 2), dtype=dtype)
|
|
104
|
+
result, state = sosfilt_samples(np.empty(0, dtype=dtype), sos, zi=zi)
|
|
105
|
+
assert result.shape == (0,)
|
|
106
|
+
assert result.dtype == dtype
|
|
107
|
+
np.testing.assert_array_equal(state, zi)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.parametrize("sos", [np.ones((2, 5)), np.ones((1, 2, 6)),
|
|
111
|
+
[[1, 0, 0, 2, 0, 0]]])
|
|
112
|
+
def test_invalid_coefficients(sos):
|
|
113
|
+
with pytest.raises(ValueError, match="sos"):
|
|
114
|
+
sosfilt_samples(np.ones(3), sos)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_invalid_state_and_output():
|
|
118
|
+
sos = butter(4, .3, output="sos")
|
|
119
|
+
values = np.ones(10)
|
|
120
|
+
with pytest.raises(ValueError, match="zi"):
|
|
121
|
+
sosfilt_samples(values, sos, zi=np.zeros((1, 2)))
|
|
122
|
+
with pytest.raises(ValueError, match="shape"):
|
|
123
|
+
sosfilt_samples(values, sos, out=np.empty(9))
|
|
124
|
+
with pytest.raises(TypeError, match="dtype"):
|
|
125
|
+
sosfilt_samples(values, sos, bits=16, out=np.empty(10))
|
|
126
|
+
with pytest.raises(ValueError, match="contiguous"):
|
|
127
|
+
sosfilt_samples(values, sos, bits=16, out=np.empty(20, dtype=np.int16)[::2])
|
|
128
|
+
with pytest.raises(TypeError, match="real signal"):
|
|
129
|
+
sosfilt_samples(values + 1j, sos, bits=16)
|
|
130
|
+
with pytest.raises(ValueError, match="full_scale"):
|
|
131
|
+
sosfilt_samples(values, sos, bits=16, full_scale=0)
|
|
132
|
+
with pytest.raises(ValueError, match="min and max"):
|
|
133
|
+
sosfilt_samples(values, sos, lower=np.nan)
|
|
134
|
+
with pytest.raises(ValueError, match="non-finite"):
|
|
135
|
+
sosfilt_samples(np.array([np.nan]), sos, bits=16)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _wave():
|
|
139
|
+
wave = .4 + .5 * wf.cos(6 * np.pi)
|
|
140
|
+
wave.start, wave.stop, wave.sample_rate = 0., 1., 1024
|
|
141
|
+
wave.min, wave.max = -.15, .27
|
|
142
|
+
return wave
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@pytest.mark.parametrize("coefficient_dtype", [np.complex128, np.longdouble])
|
|
146
|
+
def test_scipy_fallback(coefficient_dtype):
|
|
147
|
+
wave = _wave()
|
|
148
|
+
sos = butter(4, .3, output="sos").astype(coefficient_dtype)
|
|
149
|
+
if coefficient_dtype == np.complex128:
|
|
150
|
+
sos[0, 0] += .1j
|
|
151
|
+
wave.filters = sos, .03
|
|
152
|
+
raw = .4 + .5 * np.cos(6 * np.pi * np.arange(1024) / 1024)
|
|
153
|
+
expected = _clip(sosfilt(sos, raw - .03) + .03, wave.min, wave.max)
|
|
154
|
+
np.testing.assert_allclose(wave.sample(), expected, rtol=3e-13, atol=3e-14)
|
|
155
|
+
np.testing.assert_allclose(np.concatenate(list(wave.sample(chunk_size=73))),
|
|
156
|
+
expected, rtol=3e-13, atol=3e-14)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_pipeline_output_casting_and_single_section_vector():
|
|
160
|
+
wave = _wave()
|
|
161
|
+
wave.filters = butter(2, .3, output="sos")[0], .03
|
|
162
|
+
expected = wave.sample()
|
|
163
|
+
for dtype in (None, np.float32, np.float64):
|
|
164
|
+
for stride in (1, 2):
|
|
165
|
+
output = np.empty(1024 * stride)[::stride]
|
|
166
|
+
result = wave.sample(dtype=dtype, out=output)
|
|
167
|
+
assert result is output
|
|
168
|
+
np.testing.assert_array_equal(result, expected.astype(dtype or np.float64))
|
|
169
|
+
expected_i16 = quantize_samples(expected, 16)
|
|
170
|
+
np.testing.assert_array_equal(wave.sample(dtype=np.int16), expected_i16)
|
|
171
|
+
np.testing.assert_allclose(np.concatenate(list(wave.sample(chunk_size=31))),
|
|
172
|
+
expected, rtol=3e-13, atol=3e-14)
|
|
173
|
+
with pytest.raises(TypeError, match="dtype"):
|
|
174
|
+
wave.sample(dtype=np.int16, out=np.empty(1024))
|
|
175
|
+
with pytest.raises(ValueError, match="contiguous"):
|
|
176
|
+
wave.sample(out=np.empty(2048, dtype=np.int16)[::2])
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import pickle
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
from scipy.interpolate import PchipInterpolator
|
|
6
|
+
from scipy.signal import butter, sosfilt, tf2sos
|
|
7
|
+
|
|
8
|
+
import waveforms as wf
|
|
9
|
+
from waveforms._waveform import quantize_samples
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_linear_map_binary_roundtrip_clip_and_output():
|
|
13
|
+
x = np.linspace(-2.0, 2.0, 9)
|
|
14
|
+
y = 0.5 + 1.75 * x
|
|
15
|
+
mapping = wf.NonlinearMap.from_samples(
|
|
16
|
+
x, y, method="linear", table_size=9,
|
|
17
|
+
)
|
|
18
|
+
points = np.linspace(-2.0, 2.0, 1001)
|
|
19
|
+
expected = 0.5 + 1.75 * points
|
|
20
|
+
assert np.allclose(mapping(points), expected, rtol=0, atol=2e-15)
|
|
21
|
+
|
|
22
|
+
target = np.empty_like(points)
|
|
23
|
+
assert mapping(points, out=target) is target
|
|
24
|
+
assert np.array_equal(target, mapping(points))
|
|
25
|
+
assert mapping(0.25) == pytest.approx(0.9375)
|
|
26
|
+
assert mapping.method == "linear"
|
|
27
|
+
assert mapping.dtype == np.dtype(np.float64)
|
|
28
|
+
assert mapping.extrapolate == "error"
|
|
29
|
+
assert mapping.point_count == 9
|
|
30
|
+
assert mapping.domain == (-2.0, 2.0)
|
|
31
|
+
|
|
32
|
+
data = mapping.to_bytes()
|
|
33
|
+
assert data[:4] == b"NLM1"
|
|
34
|
+
restored = wf.NonlinearMap.from_bytes(data)
|
|
35
|
+
assert restored.to_bytes() is data
|
|
36
|
+
assert restored == mapping
|
|
37
|
+
assert hash(restored) == hash(mapping)
|
|
38
|
+
assert pickle.loads(pickle.dumps(mapping)) == mapping
|
|
39
|
+
assert np.array_equal(
|
|
40
|
+
mapping._apply_quantized(points, 16),
|
|
41
|
+
quantize_samples(mapping(points), 16),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
malformed = bytearray(data)
|
|
45
|
+
malformed[9] = 1
|
|
46
|
+
with pytest.raises(ValueError, match="NLM1"):
|
|
47
|
+
wf.NonlinearMap.from_bytes(malformed)
|
|
48
|
+
with pytest.raises(ValueError, match="NLM1"):
|
|
49
|
+
wf.NonlinearMap.from_bytes(data[:-1])
|
|
50
|
+
|
|
51
|
+
with pytest.raises(ValueError, match="outside its domain"):
|
|
52
|
+
mapping(np.array([-2.1, 0.0]))
|
|
53
|
+
clipped = wf.NonlinearMap.from_samples(
|
|
54
|
+
x, y, method="linear", table_size=9, extrapolate="clip",
|
|
55
|
+
)
|
|
56
|
+
assert np.array_equal(clipped([-3.0, 3.0]), [y[0], y[-1]])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_monotone_cubic_accuracy_storage_and_centering():
|
|
60
|
+
x = np.array([4.0, 4.15, 4.4, 4.9, 5.6, 6.0])
|
|
61
|
+
y = np.array([0.31, 0.27, 0.18, 0.02, -0.11, -0.16])
|
|
62
|
+
reference = 4.9
|
|
63
|
+
mapping = wf.NonlinearMap.from_samples(
|
|
64
|
+
x, y, table_size=1025, reference=reference,
|
|
65
|
+
)
|
|
66
|
+
relative = np.linspace(mapping.domain[0], mapping.domain[1], 10001)
|
|
67
|
+
reference_curve = PchipInterpolator(x, y)
|
|
68
|
+
expected = reference_curve(reference + relative) - reference_curve(reference)
|
|
69
|
+
assert np.max(np.abs(mapping(relative) - expected)) < 2e-7
|
|
70
|
+
assert mapping(0.0) == pytest.approx(0.0, abs=2e-15)
|
|
71
|
+
assert mapping.method == "monotone_cubic"
|
|
72
|
+
assert mapping.reference_input == reference
|
|
73
|
+
assert mapping.reference_output == pytest.approx(
|
|
74
|
+
reference_curve(reference), abs=2e-7)
|
|
75
|
+
assert np.all(np.diff(mapping(relative)) <= 0)
|
|
76
|
+
|
|
77
|
+
compact = wf.NonlinearMap.from_samples(
|
|
78
|
+
x, y, table_size=1025, reference=reference, dtype=np.float32,
|
|
79
|
+
)
|
|
80
|
+
assert compact.dtype == np.dtype(np.float32)
|
|
81
|
+
assert compact(0.0) == pytest.approx(0.0, abs=2e-7)
|
|
82
|
+
assert len(compact.to_bytes()) == 48 + 16 * (compact.point_count - 1)
|
|
83
|
+
assert len(mapping.to_bytes()) == 48 + 32 * (mapping.point_count - 1)
|
|
84
|
+
assert np.max(np.abs(compact(relative) - expected)) < 3e-7
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.parametrize("x,y,message", [
|
|
88
|
+
([0, 0, 1], [0, 1, 2], "increase strictly"),
|
|
89
|
+
([0, 1, 2], [0, 2, 1], "one inverse branch"),
|
|
90
|
+
([0, 1], [0, np.nan], "finite"),
|
|
91
|
+
])
|
|
92
|
+
def test_map_validation(x, y, message):
|
|
93
|
+
with pytest.raises(ValueError, match=message):
|
|
94
|
+
wf.NonlinearMap.from_samples(x, y)
|
|
95
|
+
with pytest.raises(ValueError, match="reference"):
|
|
96
|
+
wf.NonlinearMap.from_samples([0, 1], [0, 1], reference=2)
|
|
97
|
+
with pytest.raises(TypeError, match="float32 or float64"):
|
|
98
|
+
wf.NonlinearMap.from_samples([0, 1], [0, 1], dtype=np.int16)
|
|
99
|
+
with pytest.raises(ValueError, match="method"):
|
|
100
|
+
wf.NonlinearMap.from_samples([0, 1], [0, 1], method="bezier")
|
|
101
|
+
with pytest.raises(ValueError, match="NLM1"):
|
|
102
|
+
wf.NonlinearMap.from_bytes(b"not a map")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_error_controlled_table_compilation():
|
|
106
|
+
x = np.array([0.0, 0.13, 0.41, 1.0])
|
|
107
|
+
y = np.array([0.0, 0.2, 0.75, 1.0])
|
|
108
|
+
mapping = wf.NonlinearMap.from_samples(
|
|
109
|
+
x, y, method="linear", table_size=5,
|
|
110
|
+
max_error=1e-3, max_table_size=4097,
|
|
111
|
+
)
|
|
112
|
+
assert mapping.point_count > 5
|
|
113
|
+
points = np.linspace(0.0, 1.0, 100_001)
|
|
114
|
+
assert np.max(np.abs(mapping(points) - np.interp(points, x, y))) < 1e-3
|
|
115
|
+
|
|
116
|
+
with pytest.raises(ValueError, match="requires more than"):
|
|
117
|
+
wf.NonlinearMap.from_samples(
|
|
118
|
+
x, y, method="linear", table_size=5,
|
|
119
|
+
max_error=1e-6, max_table_size=9,
|
|
120
|
+
)
|
|
121
|
+
with pytest.raises(ValueError, match="max_error"):
|
|
122
|
+
wf.NonlinearMap.from_samples(x, y, max_error=0)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_waveform_sampling_order_chunking_quantization_and_pickle():
|
|
126
|
+
sample_rate = 1024
|
|
127
|
+
mapping = wf.NonlinearMap.from_samples(
|
|
128
|
+
[0.0, 0.25, 0.5, 0.75, 1.0],
|
|
129
|
+
[0.0, 0.0625, 0.25, 0.5625, 1.0],
|
|
130
|
+
table_size=257,
|
|
131
|
+
)
|
|
132
|
+
b, a = butter(3, 40.0, "lowpass", fs=sample_rate)
|
|
133
|
+
sos = tf2sos(b, a)
|
|
134
|
+
waveform = wf.t()
|
|
135
|
+
waveform.start = 0.0
|
|
136
|
+
waveform.stop = 1.0
|
|
137
|
+
waveform.sample_rate = sample_rate
|
|
138
|
+
waveform.nonlinear = mapping
|
|
139
|
+
waveform.filters = (sos, 0.0)
|
|
140
|
+
|
|
141
|
+
raw = np.arange(sample_rate, dtype=np.float64) / sample_rate
|
|
142
|
+
expected = sosfilt(sos, mapping(raw))
|
|
143
|
+
actual = waveform.sample()
|
|
144
|
+
assert np.allclose(actual, expected, rtol=3e-14, atol=3e-14)
|
|
145
|
+
chunks = np.concatenate(list(waveform.sample(chunk_size=73)))
|
|
146
|
+
assert np.allclose(chunks, expected, rtol=3e-14, atol=3e-14)
|
|
147
|
+
|
|
148
|
+
expected_int16 = quantize_samples(expected, 16)
|
|
149
|
+
output = np.empty(sample_rate, dtype=np.int16)
|
|
150
|
+
assert waveform.sample(dtype=np.int16, out=output) is output
|
|
151
|
+
assert np.array_equal(output, expected_int16)
|
|
152
|
+
|
|
153
|
+
restored = pickle.loads(pickle.dumps(waveform))
|
|
154
|
+
assert restored.nonlinear == mapping
|
|
155
|
+
assert np.array_equal(restored.sample(), actual)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_stack_mapping_happens_after_event_accumulation():
|
|
159
|
+
pulse = 0.4 * wf.square(1.0)
|
|
160
|
+
stack = wf.WaveVStack([pulse, pulse])
|
|
161
|
+
stack.start = -0.25
|
|
162
|
+
stack.stop = 0.25
|
|
163
|
+
stack.sample_rate = 1000
|
|
164
|
+
stack.nonlinear = wf.NonlinearMap.from_samples(
|
|
165
|
+
[0.0, 0.4, 0.8], [0.0, 0.16, 0.64], table_size=257,
|
|
166
|
+
)
|
|
167
|
+
samples = stack.sample()
|
|
168
|
+
assert np.allclose(samples, 0.64, atol=2e-14)
|
|
169
|
+
assert not np.allclose(samples, 0.32)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_complex_waveform_uses_explicit_component_maps():
|
|
173
|
+
real_map = wf.NonlinearMap.from_samples(
|
|
174
|
+
[0.0, 0.5, 1.0], [0.0, 0.25, 1.0], table_size=257,
|
|
175
|
+
)
|
|
176
|
+
imag_map = wf.NonlinearMap.from_samples(
|
|
177
|
+
[0.0, 0.5, 1.0], [0.0, 1.0, 2.0],
|
|
178
|
+
method="linear", table_size=257,
|
|
179
|
+
)
|
|
180
|
+
waveform = wf.ComplexWaveform(0.5, 0.25)
|
|
181
|
+
waveform.start = 0.0
|
|
182
|
+
waveform.stop = 1.0
|
|
183
|
+
waveform.sample_rate = 16
|
|
184
|
+
waveform.nonlinear = (real_map, imag_map)
|
|
185
|
+
expected = real_map(0.5) + 1j * imag_map(0.25)
|
|
186
|
+
assert np.allclose(waveform.sample(), expected)
|
|
187
|
+
i, q = waveform.sample_iq(dtype=np.int16)
|
|
188
|
+
assert np.array_equal(i, quantize_samples(
|
|
189
|
+
np.full(16, expected.real), 16))
|
|
190
|
+
assert np.array_equal(q, quantize_samples(
|
|
191
|
+
np.full(16, expected.imag), 16))
|
|
192
|
+
|
|
193
|
+
waveform.nonlinear = real_map
|
|
194
|
+
with pytest.raises(TypeError, match="complex waveforms require"):
|
|
195
|
+
waveform.sample()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@pytest.mark.parametrize("method", ["linear", "monotone_cubic"])
|
|
199
|
+
@pytest.mark.parametrize("storage", [np.float32, np.float64])
|
|
200
|
+
def test_simd_batches_match_scalar_edges_aliasing_and_quantization(
|
|
201
|
+
method, storage):
|
|
202
|
+
mapping = wf.NonlinearMap.from_samples(
|
|
203
|
+
[-1.0, -0.6, -0.1, 0.35, 1.0],
|
|
204
|
+
[-0.8, -0.5, 0.05, 0.4, 0.9],
|
|
205
|
+
method=method, table_size=257, dtype=storage, extrapolate="clip",
|
|
206
|
+
)
|
|
207
|
+
source = np.linspace(-1.2, 1.2, 65)
|
|
208
|
+
source[0] = -1.0
|
|
209
|
+
source[-1] = 1.0
|
|
210
|
+
for count in (1, 7, 8, 9, 15, 16, 17, 31, 32, 33, 65):
|
|
211
|
+
values = source[:count].copy()
|
|
212
|
+
expected = np.array([mapping(float(value)) for value in values])
|
|
213
|
+
actual = mapping(values)
|
|
214
|
+
assert np.allclose(actual, expected, rtol=2e-15, atol=2e-15)
|
|
215
|
+
|
|
216
|
+
in_place = values.copy()
|
|
217
|
+
assert mapping(in_place, out=in_place) is in_place
|
|
218
|
+
assert np.allclose(in_place, expected, rtol=2e-15, atol=2e-15)
|
|
219
|
+
|
|
220
|
+
assert np.array_equal(
|
|
221
|
+
mapping._apply_quantized(values, 16),
|
|
222
|
+
quantize_samples(expected, 16),
|
|
223
|
+
)
|
|
224
|
+
assert np.array_equal(
|
|
225
|
+
mapping._apply_quantized(values, 32),
|
|
226
|
+
quantize_samples(expected, 32),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_simd_batches_preserve_error_extrapolation_and_nonfinite_checks():
|
|
231
|
+
mapping = wf.NonlinearMap.from_samples(
|
|
232
|
+
[-1.0, 0.0, 1.0], [-0.5, 0.0, 0.5], table_size=257,
|
|
233
|
+
)
|
|
234
|
+
for bad_value in (-1.01, 1.01, np.nan, np.inf, -np.inf):
|
|
235
|
+
values = np.linspace(-0.9, 0.9, 32)
|
|
236
|
+
values[19] = bad_value
|
|
237
|
+
with pytest.raises(ValueError, match="outside its domain"):
|
|
238
|
+
mapping(values)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_simd_quantized_pipeline_spans_multiple_cache_blocks():
|
|
242
|
+
mapping = wf.NonlinearMap.from_samples(
|
|
243
|
+
[-1.0, -0.25, 0.3, 1.0], [-0.9, -0.2, 0.4, 0.95],
|
|
244
|
+
table_size=1025,
|
|
245
|
+
)
|
|
246
|
+
values = 0.99 * np.sin(np.linspace(-70.0, 70.0, 12_345))
|
|
247
|
+
mapped = mapping(values)
|
|
248
|
+
assert np.array_equal(
|
|
249
|
+
mapping._apply_quantized(values, 16),
|
|
250
|
+
quantize_samples(mapped, 16),
|
|
251
|
+
)
|
|
252
|
+
assert np.array_equal(
|
|
253
|
+
mapping._apply_quantized(values, 32),
|
|
254
|
+
quantize_samples(mapped, 32),
|
|
255
|
+
)
|