waveforms 3.5.1__tar.gz → 3.7.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.
- {waveforms-3.5.1/waveforms.egg-info → waveforms-3.7.0}/PKG-INFO +40 -10
- {waveforms-3.5.1 → waveforms-3.7.0}/README.md +39 -9
- waveforms-3.7.0/tests/test_common_pipeline.py +242 -0
- waveforms-3.7.0/tests/test_filter_parameters.py +225 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/tests/test_native_sos.py +14 -6
- {waveforms-3.5.1 → waveforms-3.7.0}/tests/test_nonlinear.py +3 -4
- {waveforms-3.5.1 → waveforms-3.7.0}/tests/test_output_limits.py +15 -9
- waveforms-3.7.0/tests/test_simd_sampling.py +136 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/tests/test_waveform.py +5 -7
- {waveforms-3.5.1 → waveforms-3.7.0}/tests/test_wavevstack.py +5 -8
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/__init__.py +2 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/_cwaveform.c +374 -175
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/_cwaveform.h +2 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/_cwaveform.md +12 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/distortion.py +26 -1
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/version.py +1 -1
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/waveform.py +222 -116
- {waveforms-3.5.1 → waveforms-3.7.0/waveforms.egg-info}/PKG-INFO +40 -10
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms.egg-info/SOURCES.txt +3 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/LICENSE +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/MANIFEST.in +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/pyproject.toml +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/setup.cfg +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/setup.py +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/tests/test_core.py +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/Waveform.g4 +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/WaveformLexer.py +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/WaveformListener.py +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/WaveformParser.py +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/__main__.py +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/_waveform.pyi +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/_waveform.pyx +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/nonlinear.py +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/utils.py +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/waveform_parser.py +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms.egg-info/dependency_links.txt +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms.egg-info/entry_points.txt +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/waveforms.egg-info/requires.txt +0 -0
- {waveforms-3.5.1 → waveforms-3.7.0}/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.7.0
|
|
4
4
|
Summary: Edit waveforms used in experiment
|
|
5
5
|
Author-email: feihoo87 <feihoo87@gmail.com>
|
|
6
6
|
Maintainer-email: feihoo87 <feihoo87@gmail.com>
|
|
@@ -202,21 +202,51 @@ uses the same final limits. The filter state continues from the unclipped
|
|
|
202
202
|
filtered signal across chunks. DAC `full_scale` controls integer conversion
|
|
203
203
|
and saturation separately from these amplitude limits.
|
|
204
204
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
205
|
+
`filters` is a `defaultdict(float)` mapping exponential cascade time constants
|
|
206
|
+
`tau` (in seconds) to amplitudes `amp`, independent of the sampling clock:
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
stack.filters[100e-9] += 0.12
|
|
210
|
+
stack.filters[2e-6] += -0.04
|
|
211
|
+
samples = stack.sample(sample_rate=2_400_000_000)
|
|
212
|
+
unfiltered = stack.sample(filters={}) # bypass predistortion for this call
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
The parameters describe cascade stages, not a parallel sum of exponential
|
|
216
|
+
terms. Sampling uses `exp_decay_filter_from_cascade` followed by
|
|
217
|
+
`exp_decay_filter(..., inv=True, output="sos")` at the actual sample rate:
|
|
218
|
+
filtering defaults to **predistortion**. Coefficients are cached by parameter
|
|
219
|
+
values and sample rate; changing an amplitude or the clock takes effect on the
|
|
220
|
+
next sampling call. Only the parameters are saved by pickle, not cached SOS
|
|
221
|
+
coefficients. Assign a mapping to replace them, or `None` to clear them. The old
|
|
222
|
+
`(sos, initial)` representation is no longer accepted.
|
|
223
|
+
|
|
224
|
+
The initial level is the first sample **after nonlinear mapping**, before
|
|
225
|
+
output limiting: the system is assumed to have held that level indefinitely
|
|
226
|
+
before playback. This stage subtracts that baseline, applies the inverse
|
|
227
|
+
filter, restores the baseline, and applies the final limits. Chunked sampling
|
|
228
|
+
retains both the initial level and the unclipped filter state across chunks;
|
|
229
|
+
complex signals use the first I and Q values independently.
|
|
230
|
+
|
|
231
|
+
SOS filtering runs in the C core for float64 and complex128 signals.
|
|
208
232
|
Real int16/int32 output is quantized in the same stage, without allocating a
|
|
209
233
|
full filtered floating-point buffer. Float output can reuse the sampled
|
|
210
234
|
buffer. A small fixed scratch buffer keeps filtering and conversion local;
|
|
211
|
-
waveform evaluation and nonlinear mapping still precede this stage.
|
|
212
|
-
coefficients and extended precision retain the SciPy implementation.
|
|
235
|
+
waveform evaluation and nonlinear mapping still precede this stage.
|
|
213
236
|
`sample_iq()` still converts the filtered complex result into separate I/Q
|
|
214
237
|
output arrays. See [the SOS benchmark](benchmarks/sos_filter.md).
|
|
215
238
|
|
|
216
|
-
Calling `waveform(t)`
|
|
217
|
-
nonlinear
|
|
218
|
-
|
|
219
|
-
|
|
239
|
+
Calling `waveform(t)` applies waveform/event accumulation, the object's
|
|
240
|
+
nonlinear mapping, and then its own amplitude limits. It does not apply SOS
|
|
241
|
+
filters or DAC quantization. Stacks and complex wrappers use raw child values;
|
|
242
|
+
child maps and limits do not affect the parent's processing chain. Thus direct
|
|
243
|
+
evaluation and unfiltered floating-point sampling have the same processing
|
|
244
|
+
semantics on the same time grid, subject to floating-point rounding. With
|
|
245
|
+
`out=` and `accumulate=True`, the processed contribution is added to the
|
|
246
|
+
existing buffer without mapping or limiting its previous contents. Sampling
|
|
247
|
+
starts from raw values so mapping and limits are each applied only once, with
|
|
248
|
+
SOS filtering between them when configured. Maps and limits are metadata
|
|
249
|
+
preserved by pickle, not part of the raw WNF4/WNS4 binary blocks.
|
|
220
250
|
|
|
221
251
|
## Reporting Issues
|
|
222
252
|
Please report all issues [on github](https://github.com/feihoo87/waveforms/issues).
|
|
@@ -158,21 +158,51 @@ uses the same final limits. The filter state continues from the unclipped
|
|
|
158
158
|
filtered signal across chunks. DAC `full_scale` controls integer conversion
|
|
159
159
|
and saturation separately from these amplitude limits.
|
|
160
160
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
`filters` is a `defaultdict(float)` mapping exponential cascade time constants
|
|
162
|
+
`tau` (in seconds) to amplitudes `amp`, independent of the sampling clock:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
stack.filters[100e-9] += 0.12
|
|
166
|
+
stack.filters[2e-6] += -0.04
|
|
167
|
+
samples = stack.sample(sample_rate=2_400_000_000)
|
|
168
|
+
unfiltered = stack.sample(filters={}) # bypass predistortion for this call
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The parameters describe cascade stages, not a parallel sum of exponential
|
|
172
|
+
terms. Sampling uses `exp_decay_filter_from_cascade` followed by
|
|
173
|
+
`exp_decay_filter(..., inv=True, output="sos")` at the actual sample rate:
|
|
174
|
+
filtering defaults to **predistortion**. Coefficients are cached by parameter
|
|
175
|
+
values and sample rate; changing an amplitude or the clock takes effect on the
|
|
176
|
+
next sampling call. Only the parameters are saved by pickle, not cached SOS
|
|
177
|
+
coefficients. Assign a mapping to replace them, or `None` to clear them. The old
|
|
178
|
+
`(sos, initial)` representation is no longer accepted.
|
|
179
|
+
|
|
180
|
+
The initial level is the first sample **after nonlinear mapping**, before
|
|
181
|
+
output limiting: the system is assumed to have held that level indefinitely
|
|
182
|
+
before playback. This stage subtracts that baseline, applies the inverse
|
|
183
|
+
filter, restores the baseline, and applies the final limits. Chunked sampling
|
|
184
|
+
retains both the initial level and the unclipped filter state across chunks;
|
|
185
|
+
complex signals use the first I and Q values independently.
|
|
186
|
+
|
|
187
|
+
SOS filtering runs in the C core for float64 and complex128 signals.
|
|
164
188
|
Real int16/int32 output is quantized in the same stage, without allocating a
|
|
165
189
|
full filtered floating-point buffer. Float output can reuse the sampled
|
|
166
190
|
buffer. A small fixed scratch buffer keeps filtering and conversion local;
|
|
167
|
-
waveform evaluation and nonlinear mapping still precede this stage.
|
|
168
|
-
coefficients and extended precision retain the SciPy implementation.
|
|
191
|
+
waveform evaluation and nonlinear mapping still precede this stage.
|
|
169
192
|
`sample_iq()` still converts the filtered complex result into separate I/Q
|
|
170
193
|
output arrays. See [the SOS benchmark](benchmarks/sos_filter.md).
|
|
171
194
|
|
|
172
|
-
Calling `waveform(t)`
|
|
173
|
-
nonlinear
|
|
174
|
-
|
|
175
|
-
|
|
195
|
+
Calling `waveform(t)` applies waveform/event accumulation, the object's
|
|
196
|
+
nonlinear mapping, and then its own amplitude limits. It does not apply SOS
|
|
197
|
+
filters or DAC quantization. Stacks and complex wrappers use raw child values;
|
|
198
|
+
child maps and limits do not affect the parent's processing chain. Thus direct
|
|
199
|
+
evaluation and unfiltered floating-point sampling have the same processing
|
|
200
|
+
semantics on the same time grid, subject to floating-point rounding. With
|
|
201
|
+
`out=` and `accumulate=True`, the processed contribution is added to the
|
|
202
|
+
existing buffer without mapping or limiting its previous contents. Sampling
|
|
203
|
+
starts from raw values so mapping and limits are each applied only once, with
|
|
204
|
+
SOS filtering between them when configured. Maps and limits are metadata
|
|
205
|
+
preserved by pickle, not part of the raw WNF4/WNS4 binary blocks.
|
|
176
206
|
|
|
177
207
|
## Reporting Issues
|
|
178
208
|
Please report all issues [on github](https://github.com/feihoo87/waveforms/issues).
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Regression coverage for direct native loading and output placement."""
|
|
2
|
+
|
|
3
|
+
import ctypes
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
import importlib
|
|
6
|
+
import pickle
|
|
7
|
+
import struct
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
import waveforms as wf
|
|
13
|
+
import waveforms._waveform as core
|
|
14
|
+
from waveforms._waveform import quantize_samples
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(scope='module')
|
|
18
|
+
def native():
|
|
19
|
+
library = ctypes.CDLL(core.__file__)
|
|
20
|
+
library.cwaveform_stack_from_bytes.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
|
|
21
|
+
library.cwaveform_stack_from_bytes.restype = ctypes.c_void_p
|
|
22
|
+
library.cwaveform_stack_release.argtypes = [ctypes.c_void_p]
|
|
23
|
+
library.cwaveform_stack_release.restype = None
|
|
24
|
+
library.cwaveform_stack_bytes.argtypes = [ctypes.c_void_p,
|
|
25
|
+
ctypes.POINTER(ctypes.c_size_t)]
|
|
26
|
+
library.cwaveform_stack_bytes.restype = ctypes.c_void_p
|
|
27
|
+
library.cwaveform_stack_hash.argtypes = [ctypes.c_void_p]
|
|
28
|
+
library.cwaveform_stack_hash.restype = ctypes.c_uint64
|
|
29
|
+
return library
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _stack(delays, *, scales=None):
|
|
33
|
+
delays = np.asarray(delays, dtype=np.int64)
|
|
34
|
+
if scales is None:
|
|
35
|
+
scales = np.resize([.5, -.75, 1.0], len(delays))
|
|
36
|
+
stack = wf.WaveVStack.from_events(
|
|
37
|
+
[.8 * wf.square(8 / 120e9), -.3 * wf.square(4 / 120e9)],
|
|
38
|
+
np.arange(len(delays), dtype=np.uint32) % 2, delays, scales)
|
|
39
|
+
stack.start, stack.stop, stack.sample_rate = 0, 120 / 120e9, 120_000_000_000
|
|
40
|
+
stack.offset = .07
|
|
41
|
+
return stack
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_native_decode_owns_exact_bytes_and_preserves_hash(native):
|
|
45
|
+
original = _stack([-9, 0, 19, 33, 61, 118, 130])
|
|
46
|
+
data = original._core.to_bytes()
|
|
47
|
+
source = ctypes.create_string_buffer(data)
|
|
48
|
+
handle = native.cwaveform_stack_from_bytes(source, len(data))
|
|
49
|
+
assert handle
|
|
50
|
+
try:
|
|
51
|
+
# Mutating the transport buffer must not change the native handle.
|
|
52
|
+
ctypes.memset(source, 0, len(data))
|
|
53
|
+
size = ctypes.c_size_t()
|
|
54
|
+
pointer = native.cwaveform_stack_bytes(handle, ctypes.byref(size))
|
|
55
|
+
assert size.value == len(data)
|
|
56
|
+
assert ctypes.string_at(pointer, size.value) == data
|
|
57
|
+
assert native.cwaveform_stack_hash(handle) == original._core.hash64
|
|
58
|
+
finally:
|
|
59
|
+
native.cwaveform_stack_release(handle)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_lazy_native_hash_matches_format_and_is_safe_for_parallel_readers(native):
|
|
63
|
+
stack = _stack(np.arange(10000) * 13)
|
|
64
|
+
data = stack._core.to_bytes()
|
|
65
|
+
expected = 1469598103934665603
|
|
66
|
+
for byte in data:
|
|
67
|
+
expected = ((expected ^ byte) * 1099511628211) & ((1 << 64) - 1)
|
|
68
|
+
source = ctypes.create_string_buffer(data)
|
|
69
|
+
handle = native.cwaveform_stack_from_bytes(source, len(data))
|
|
70
|
+
assert handle
|
|
71
|
+
try:
|
|
72
|
+
with ThreadPoolExecutor(max_workers=8) as workers:
|
|
73
|
+
hashes = list(workers.map(
|
|
74
|
+
lambda _: native.cwaveform_stack_hash(handle), range(64)))
|
|
75
|
+
assert hashes == [expected] * len(hashes)
|
|
76
|
+
assert stack._core.hash64 == expected
|
|
77
|
+
finally:
|
|
78
|
+
native.cwaveform_stack_release(handle)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_native_decode_rejects_invalid_blocks_and_releases_partial_loads(native):
|
|
82
|
+
data = _stack([0, 16, 32])._core.to_bytes()
|
|
83
|
+
malformed = [data[:index] for index in range(len(data))]
|
|
84
|
+
event_offset = len(data) - 3 * 20
|
|
85
|
+
for offset, fmt, value in (
|
|
86
|
+
(0, '4s', b'BAD!'), (4, 'H', 65535), (6, 'H', 1),
|
|
87
|
+
(8, 'I', 2**32 - 1), (12, 'I', 2**32 - 1),
|
|
88
|
+
(16, 'I', 2**32 - 1),
|
|
89
|
+
(24 + 24, 'B', 255), # First template's first node.
|
|
90
|
+
(event_offset, 'I', 2),
|
|
91
|
+
(event_offset + 12 * 3, 'd', np.nan),
|
|
92
|
+
(event_offset + 12 * 3 + 8, 'd', np.inf),
|
|
93
|
+
(event_offset + 12 * 3 + 16, 'd', -np.inf),
|
|
94
|
+
):
|
|
95
|
+
candidate = bytearray(data)
|
|
96
|
+
struct.pack_into('<' + fmt, candidate, offset, value)
|
|
97
|
+
malformed.append(bytes(candidate))
|
|
98
|
+
for candidate in malformed:
|
|
99
|
+
source = ctypes.create_string_buffer(candidate)
|
|
100
|
+
handle = native.cwaveform_stack_from_bytes(source, len(candidate))
|
|
101
|
+
try:
|
|
102
|
+
assert not handle
|
|
103
|
+
finally:
|
|
104
|
+
if handle:
|
|
105
|
+
native.cwaveform_stack_release(handle)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.mark.parametrize('delays', [[], [-30, -20], [140, 150], [0, 119],
|
|
109
|
+
[4, 10, 16, 22], [8, 30, 70],
|
|
110
|
+
[8, 30, 31], [80, 30, 8]])
|
|
111
|
+
@pytest.mark.parametrize('bits', [0, 16, 32])
|
|
112
|
+
@pytest.mark.parametrize('bounds', [(-np.inf, np.inf), (.1, .2)])
|
|
113
|
+
def test_gap_initialization_edges_overlap_and_out_of_order(delays, bits, bounds):
|
|
114
|
+
stack = _stack(delays)
|
|
115
|
+
stack.min, stack.max = bounds
|
|
116
|
+
restored = pickle.loads(pickle.dumps(stack)) >> (1 / 120e9)
|
|
117
|
+
expected = restored._core.sample(0, 120, 1, 1, 1, stack.offset)
|
|
118
|
+
np.clip(expected, *bounds, out=expected)
|
|
119
|
+
if bits:
|
|
120
|
+
expected = quantize_samples(expected, bits, .7)
|
|
121
|
+
output = np.full_like(expected, 17)
|
|
122
|
+
assert restored.sample(out=output, full_scale=.7) is output
|
|
123
|
+
if bits:
|
|
124
|
+
np.testing.assert_array_equal(output, expected)
|
|
125
|
+
else:
|
|
126
|
+
# The plan groups by template; overlapping additions can differ from
|
|
127
|
+
# the event-order evaluator by a rounding bit even before this change.
|
|
128
|
+
np.testing.assert_allclose(output, expected, rtol=1e-15, atol=1e-15)
|
|
129
|
+
first = output.copy()
|
|
130
|
+
output.fill(23)
|
|
131
|
+
restored.sample(out=output, full_scale=.7)
|
|
132
|
+
np.testing.assert_array_equal(output, first)
|
|
133
|
+
reference = stack >> (1 / 120e9)
|
|
134
|
+
assert restored.begin == reference.begin
|
|
135
|
+
assert restored.end == reference.end
|
|
136
|
+
assert restored._core.lower_tick == stack._core.lower_tick
|
|
137
|
+
assert restored._core.upper_tick == stack._core.upper_tick
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_gap_initialization_skips_zero_scales_and_empty_templates():
|
|
141
|
+
stack = _stack([8, 30, 70], scales=[0, 1, 0])
|
|
142
|
+
expected = stack._core.sample(0, 120, 1, 1, 0, stack.offset)
|
|
143
|
+
np.testing.assert_array_equal(stack.sample(), expected)
|
|
144
|
+
empty = wf.WaveVStack.from_events([wf.zero()], [0], [0], [1.0])
|
|
145
|
+
empty.start, empty.stop, empty.sample_rate = 0, 1e-9, 10_000_000_000
|
|
146
|
+
np.testing.assert_array_equal(empty.sample(), np.zeros(10))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.parametrize('bits', [0, 16, 32])
|
|
150
|
+
def test_many_scales_and_sparse_output(bits):
|
|
151
|
+
stack = _stack(np.arange(200) * 13, scales=np.linspace(-.8, .8, 200))
|
|
152
|
+
stack.stop = 2700 / 120e9
|
|
153
|
+
stack.min, stack.max = -.2, .25
|
|
154
|
+
expected = stack._core.sample(0, 2700, 1, 1, 0, stack.offset)
|
|
155
|
+
np.clip(expected, -.2, .25, out=expected)
|
|
156
|
+
if bits:
|
|
157
|
+
expected = quantize_samples(expected, bits, .7)
|
|
158
|
+
output = np.full_like(expected, 17)
|
|
159
|
+
stack.sample(out=output, full_scale=.7)
|
|
160
|
+
np.testing.assert_array_equal(output, expected)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@pytest.mark.parametrize('templates_count', [1, 8, 9, 16, 33, 129])
|
|
164
|
+
def test_plan_lookup_growth_repeated_keys_and_multiple_tick_phases(templates_count):
|
|
165
|
+
rng = np.random.default_rng(417)
|
|
166
|
+
templates = [wf.square((100 + index) / 120e9)
|
|
167
|
+
for index in range(templates_count)]
|
|
168
|
+
count = 2 * templates_count + 100
|
|
169
|
+
ids = np.arange(count, dtype=np.uint32) % templates_count
|
|
170
|
+
delays = np.arange(count, dtype=np.int64) * 500 + rng.integers(0, 50, count)
|
|
171
|
+
scales = np.resize([.125, -.25, .5], count)
|
|
172
|
+
stack = wf.WaveVStack.from_events(templates, ids, delays, scales)
|
|
173
|
+
stack.start, stack.stop, stack.sample_rate = 0, count * 500 / 120e9, 2_400_000_000
|
|
174
|
+
restored = pickle.loads(pickle.dumps(stack)) >> (7 / 120e9)
|
|
175
|
+
samples = count * 10
|
|
176
|
+
expected = restored._core.sample(0, samples, 50, 1, 7, 0)
|
|
177
|
+
np.testing.assert_array_equal(restored.sample(), expected)
|
|
178
|
+
plan = restored._sample_plan_cache[1]
|
|
179
|
+
assert plan.group_count == len(set(zip(ids, (-(delays + 7)) % 50)))
|
|
180
|
+
np.testing.assert_array_equal(restored.sample(), expected)
|
|
181
|
+
# Rebuilding with a different calibration changes phases, not the format.
|
|
182
|
+
restored.shift = 11 / 120e9
|
|
183
|
+
expected = restored._core.sample(0, samples, 50, 1, 11, 0)
|
|
184
|
+
np.testing.assert_array_equal(restored.sample(), expected)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@pytest.mark.parametrize('stack', [False, True])
|
|
188
|
+
@pytest.mark.parametrize('rate', [2_400_000_000, 1_234_567_891])
|
|
189
|
+
def test_float_output_is_written_directly(stack, rate, monkeypatch):
|
|
190
|
+
module = importlib.import_module('waveforms.waveform')
|
|
191
|
+
wave = wf.cos(2 * np.pi * 30e6)
|
|
192
|
+
if stack:
|
|
193
|
+
wave = wf.WaveVStack([wave * wf.square(2e-6)])
|
|
194
|
+
wave.start, wave.stop, wave.sample_rate = 0, 1e-6, rate
|
|
195
|
+
expected = wave.sample()
|
|
196
|
+
output = np.empty_like(expected)
|
|
197
|
+
finish = module._finish_samples
|
|
198
|
+
|
|
199
|
+
def check_direct(values, dtype, full_scale, out, *args):
|
|
200
|
+
assert values is output and out is output
|
|
201
|
+
return finish(values, dtype, full_scale, out, *args)
|
|
202
|
+
|
|
203
|
+
monkeypatch.setattr(module, '_finish_samples', check_direct)
|
|
204
|
+
assert wave.sample(out=output) is output
|
|
205
|
+
np.testing.assert_array_equal(output, expected)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@pytest.mark.parametrize('stack', [False, True])
|
|
209
|
+
@pytest.mark.parametrize('dtype', [None, np.float32, np.float64, np.complex128])
|
|
210
|
+
@pytest.mark.parametrize('layout', ['contiguous', 'strided', 'unaligned', 'big_endian'])
|
|
211
|
+
def test_output_casting_and_layout_fallbacks(stack, dtype, layout):
|
|
212
|
+
wave = .8 * wf.cos(2 * np.pi * 30e6)
|
|
213
|
+
if stack:
|
|
214
|
+
wave = wf.WaveVStack([wave * wf.square(2e-6)])
|
|
215
|
+
wave.start, wave.stop, wave.sample_rate = 0, 1e-6, 2_400_000_000
|
|
216
|
+
expected = wave.sample(dtype=dtype)
|
|
217
|
+
output_dtype = np.complex128 if dtype == np.complex128 else np.float64
|
|
218
|
+
if layout == 'strided':
|
|
219
|
+
output = np.empty(2 * len(expected), dtype=output_dtype)[::2]
|
|
220
|
+
elif layout == 'unaligned':
|
|
221
|
+
output = np.ndarray(expected.shape, dtype=output_dtype,
|
|
222
|
+
buffer=bytearray(expected.size * np.dtype(output_dtype).itemsize + 1),
|
|
223
|
+
offset=1)
|
|
224
|
+
else:
|
|
225
|
+
if layout == 'big_endian':
|
|
226
|
+
output_dtype = np.dtype(output_dtype).newbyteorder('>')
|
|
227
|
+
output = np.empty(expected.shape, dtype=output_dtype)
|
|
228
|
+
assert wave.sample(dtype=dtype, out=output) is output
|
|
229
|
+
np.testing.assert_array_equal(output, expected)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@pytest.mark.parametrize('stack', [False, True])
|
|
233
|
+
def test_float_output_validation(stack):
|
|
234
|
+
wave = _stack([10, 30]) if stack else wf.cos(1)
|
|
235
|
+
wave.start, wave.stop, wave.sample_rate = 0, 1e-6, 1_000_000_000
|
|
236
|
+
for output in (np.empty(999), np.empty((1, 1000))):
|
|
237
|
+
with pytest.raises(ValueError, match='shape'):
|
|
238
|
+
wave.sample(out=output)
|
|
239
|
+
output = np.empty(1000)
|
|
240
|
+
output.flags.writeable = False
|
|
241
|
+
with pytest.raises(ValueError, match='writ'):
|
|
242
|
+
wave.sample(out=output)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Clock-independent predistortion parameters and first-sample prehistory."""
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
import copy
|
|
5
|
+
import pickle
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pytest
|
|
9
|
+
from scipy.signal import sosfilt
|
|
10
|
+
|
|
11
|
+
import waveforms as wf
|
|
12
|
+
import waveforms.waveform as impl
|
|
13
|
+
from waveforms._waveform import quantize_samples
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
KINDS = ("real", "stack", "complex", "complex_stack")
|
|
17
|
+
STAGES = {50e-9: .12, 200e-9: -.04, 1e-6: .08}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _wave(kind, constant=False):
|
|
21
|
+
real = wf.const(.375) if constant else .125 + .25 * wf.cos(2 * np.pi * 7e6)
|
|
22
|
+
imag = wf.const(-.125) if constant else -.125 + .125 * wf.sin(2 * np.pi * 11e6)
|
|
23
|
+
if "stack" in kind:
|
|
24
|
+
real = wf.WaveVStack([.5 * real, .5 * real])
|
|
25
|
+
imag = wf.WaveVStack([.25 * imag, .75 * imag])
|
|
26
|
+
if kind == "complex":
|
|
27
|
+
real = wf.ComplexWaveform(real, imag)
|
|
28
|
+
elif kind == "complex_stack":
|
|
29
|
+
real = wf.ComplexWaveVStack(real, imag)
|
|
30
|
+
real.start, real.stop, real.sample_rate = 0., 256e-9, 1_000_000_000
|
|
31
|
+
return real
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _reference(raw, stages, rate):
|
|
35
|
+
amp, tau = wf.exp_decay_filter_from_cascade(
|
|
36
|
+
[(amp, tau) for tau, amp in sorted(stages.items()) if amp != 0])
|
|
37
|
+
sos = wf.exp_decay_filter(amp, tau, rate, inv=True, output="sos")
|
|
38
|
+
return sosfilt(sos, raw - raw[0]) + raw[0]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.parametrize("kind", KINDS)
|
|
42
|
+
def test_default_mapping_assignment_and_independent_copies(kind):
|
|
43
|
+
wave = _wave(kind)
|
|
44
|
+
other = _wave(kind)
|
|
45
|
+
assert isinstance(wave.filters, defaultdict)
|
|
46
|
+
assert wave.filters.default_factory is float
|
|
47
|
+
assert wave.filters[50e-9] == 0
|
|
48
|
+
wave.filters[50e-9] += .12
|
|
49
|
+
assert other.filters == {}
|
|
50
|
+
params = defaultdict(lambda: 99, STAGES)
|
|
51
|
+
wave.filters = params
|
|
52
|
+
params[50e-9] = .9
|
|
53
|
+
assert wave.filters == STAGES
|
|
54
|
+
assert wave.filters.default_factory is float
|
|
55
|
+
for duplicate in (copy.copy(wave), copy.deepcopy(wave),
|
|
56
|
+
pickle.loads(pickle.dumps(wave))):
|
|
57
|
+
assert isinstance(duplicate.filters, defaultdict)
|
|
58
|
+
assert duplicate.filters == STAGES
|
|
59
|
+
assert duplicate.filters is not wave.filters
|
|
60
|
+
duplicate.filters[50e-9] += .05
|
|
61
|
+
assert wave.filters == STAGES
|
|
62
|
+
wave.filters = None
|
|
63
|
+
assert isinstance(wave.filters, defaultdict)
|
|
64
|
+
assert wave.filters == {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.parametrize("kind", ["stack", "complex_stack"])
|
|
68
|
+
def test_delay_calibration_preserves_but_does_not_alias_filters(kind):
|
|
69
|
+
wave = _wave(kind)
|
|
70
|
+
wave.filters = STAGES
|
|
71
|
+
restored = pickle.loads(pickle.dumps(wave))
|
|
72
|
+
shifted = restored >> 2e-9
|
|
73
|
+
assert shifted.filters == STAGES
|
|
74
|
+
raw = shifted.sample(filters={})
|
|
75
|
+
np.testing.assert_allclose(shifted.sample(),
|
|
76
|
+
_reference(raw, STAGES, shifted.sample_rate),
|
|
77
|
+
rtol=3e-12, atol=3e-13)
|
|
78
|
+
shifted.filters[50e-9] += .01
|
|
79
|
+
assert restored.filters == STAGES
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.parametrize("kind", KINDS)
|
|
83
|
+
@pytest.mark.parametrize("rate", [500_000_000, 1_200_000_000, 2_400_000_000,
|
|
84
|
+
7_000_000_000, 1_234_567_890.5])
|
|
85
|
+
def test_rate_override_inverse_filter_and_chunked_first_sample(kind, rate):
|
|
86
|
+
wave = _wave(kind)
|
|
87
|
+
wave.filters = STAGES
|
|
88
|
+
raw = wave.sample(rate, filters={})
|
|
89
|
+
expected = _reference(raw, STAGES, rate)
|
|
90
|
+
actual = wave.sample(rate)
|
|
91
|
+
assert actual[0] == raw[0]
|
|
92
|
+
assert not np.allclose(actual, raw)
|
|
93
|
+
# Native real/imag recurrences and SciPy complex arithmetic round slightly
|
|
94
|
+
# differently for poles this close to one (small accumulated roundoff).
|
|
95
|
+
np.testing.assert_allclose(actual, expected, rtol=3e-12, atol=2e-12)
|
|
96
|
+
for chunk_size in (1, 31, 256, len(raw) + 1):
|
|
97
|
+
target = np.empty_like(expected)
|
|
98
|
+
chunks = list(wave.sample(rate, chunk_size=chunk_size, out=target))
|
|
99
|
+
np.testing.assert_allclose(np.concatenate(chunks), expected,
|
|
100
|
+
rtol=3e-12, atol=2e-12)
|
|
101
|
+
np.testing.assert_allclose(target, expected, rtol=3e-12, atol=2e-12)
|
|
102
|
+
# Changing the device clock must not modify the serialized calibration.
|
|
103
|
+
assert wave.sample_rate == 1_000_000_000
|
|
104
|
+
assert wave.filters == STAGES
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.parametrize("kind", KINDS)
|
|
108
|
+
def test_constant_prehistory_has_no_startup_transient_after_mapping(kind):
|
|
109
|
+
wave = _wave(kind, constant=True)
|
|
110
|
+
wave.filters = STAGES
|
|
111
|
+
mapping = wf.NonlinearMap.from_samples(
|
|
112
|
+
[-1., 1.], [-.5, 1.5], method="linear", table_size=2)
|
|
113
|
+
wave.nonlinear = (mapping, mapping) if "complex" in kind else mapping
|
|
114
|
+
expected = .875 + .375j if "complex" in kind else .875
|
|
115
|
+
np.testing.assert_array_equal(wave.sample(), np.full(256, expected))
|
|
116
|
+
chunks = np.concatenate(list(wave.sample(chunk_size=7)))
|
|
117
|
+
np.testing.assert_array_equal(chunks, np.full(256, expected))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.parametrize("kind", KINDS)
|
|
121
|
+
@pytest.mark.parametrize("dtype", [np.int16, np.int32])
|
|
122
|
+
def test_initial_level_precedes_clipping_and_quantization(kind, dtype):
|
|
123
|
+
wave = _wave(kind)
|
|
124
|
+
wave.filters = STAGES
|
|
125
|
+
mapping = wf.NonlinearMap.from_samples(
|
|
126
|
+
[-1., 1.], [-.75, 1.25], method="linear", table_size=2)
|
|
127
|
+
wave.nonlinear = (mapping, mapping) if "complex" in kind else mapping
|
|
128
|
+
raw = wave.sample(filters={})
|
|
129
|
+
filtered = _reference(raw, STAGES, wave.sample_rate)
|
|
130
|
+
wave.min, wave.max = -.1, .2
|
|
131
|
+
bits = np.dtype(dtype).itemsize * 8
|
|
132
|
+
if "complex" in kind:
|
|
133
|
+
expected = tuple(quantize_samples(np.clip(v, -.1, .2), bits)
|
|
134
|
+
for v in (filtered.real, filtered.imag))
|
|
135
|
+
actual = wave.sample_iq(dtype=dtype)
|
|
136
|
+
for component, reference in zip(actual, expected):
|
|
137
|
+
np.testing.assert_array_equal(component, reference)
|
|
138
|
+
chunks = list(wave.sample_iq(dtype=dtype, chunk_size=1))
|
|
139
|
+
for index in (0, 1):
|
|
140
|
+
np.testing.assert_array_equal(
|
|
141
|
+
np.concatenate([chunk[index] for chunk in chunks]), expected[index])
|
|
142
|
+
else:
|
|
143
|
+
expected = quantize_samples(np.clip(filtered, -.1, .2), bits)
|
|
144
|
+
np.testing.assert_array_equal(wave.sample(dtype=dtype), expected)
|
|
145
|
+
np.testing.assert_array_equal(
|
|
146
|
+
np.concatenate(list(wave.sample(dtype=dtype, chunk_size=1))), expected)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.parametrize("kind", KINDS)
|
|
150
|
+
def test_empty_and_single_sample(kind):
|
|
151
|
+
wave = _wave(kind)
|
|
152
|
+
wave.filters = STAGES
|
|
153
|
+
wave.stop = wave.start
|
|
154
|
+
assert wave.sample().size == 0
|
|
155
|
+
assert list(wave.sample(chunk_size=1)) == []
|
|
156
|
+
wave.stop = 1e-9
|
|
157
|
+
expected = wave.sample(filters={})
|
|
158
|
+
assert len(expected) == 1
|
|
159
|
+
np.testing.assert_array_equal(wave.sample(), expected)
|
|
160
|
+
np.testing.assert_array_equal(np.concatenate(list(wave.sample(chunk_size=1))), expected)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_cache_tracks_values_and_rate_but_not_initial_or_filter_state():
|
|
164
|
+
impl._exp_decay_sos.cache_clear()
|
|
165
|
+
wave = _wave("real")
|
|
166
|
+
wave.filters = STAGES
|
|
167
|
+
before = pickle.dumps(wave)
|
|
168
|
+
initial_result = wave.sample()
|
|
169
|
+
assert impl._exp_decay_sos.cache_info().misses == 1
|
|
170
|
+
assert pickle.dumps(wave) == before
|
|
171
|
+
np.testing.assert_array_equal(wave.sample(), initial_result)
|
|
172
|
+
wave.filters = dict(reversed(list(STAGES.items())))
|
|
173
|
+
wave.filters[2e-6] # Reading a missing tau must not invalidate the cache.
|
|
174
|
+
np.testing.assert_array_equal(wave.sample(), initial_result)
|
|
175
|
+
assert impl._exp_decay_sos.cache_info().misses == 1
|
|
176
|
+
# A different signal shares coefficients, not its first point or state.
|
|
177
|
+
other = _wave("complex")
|
|
178
|
+
other.filters = STAGES
|
|
179
|
+
other.sample()
|
|
180
|
+
assert impl._exp_decay_sos.cache_info().misses == 1
|
|
181
|
+
wave.filters[50e-9] += .1
|
|
182
|
+
assert not np.allclose(wave.sample(), initial_result)
|
|
183
|
+
assert impl._exp_decay_sos.cache_info().misses == 2
|
|
184
|
+
wave.sample(2_400_000_000)
|
|
185
|
+
assert impl._exp_decay_sos.cache_info().misses == 3
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_explicit_mapping_override_and_snapshot_during_stream():
|
|
189
|
+
wave = _wave("real")
|
|
190
|
+
wave.filters = STAGES
|
|
191
|
+
raw = wave.sample(filters={})
|
|
192
|
+
override = {75e-9: .2}
|
|
193
|
+
expected = _reference(raw, override, wave.sample_rate)
|
|
194
|
+
stream = wave.sample(filters=override, chunk_size=31)
|
|
195
|
+
chunks = [next(stream)]
|
|
196
|
+
override[75e-9] = .4
|
|
197
|
+
wave.filters[50e-9] = .5
|
|
198
|
+
chunks.extend(stream)
|
|
199
|
+
np.testing.assert_allclose(np.concatenate(chunks), expected,
|
|
200
|
+
rtol=3e-12, atol=3e-13)
|
|
201
|
+
np.testing.assert_array_equal(wave.sample(filters={75e-9: 0}), raw)
|
|
202
|
+
np.testing.assert_array_equal(wave.sample(filters={}), raw)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@pytest.mark.parametrize("params,message", [
|
|
206
|
+
({0.: .1}, "positive"), ({-1.: .1}, "positive"),
|
|
207
|
+
({np.inf: .1}, "finite"), ({np.nan: .1}, "finite"),
|
|
208
|
+
({1e-6: np.nan}, "finite"), ({1e-6: np.inf}, "finite"),
|
|
209
|
+
])
|
|
210
|
+
def test_invalid_active_parameters(params, message):
|
|
211
|
+
wave = _wave("real")
|
|
212
|
+
wave.filters = params
|
|
213
|
+
with pytest.raises(ValueError, match=message):
|
|
214
|
+
wave.sample()
|
|
215
|
+
with pytest.raises(ValueError, match=message):
|
|
216
|
+
list(wave.sample(chunk_size=7))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_old_sos_tuple_is_not_a_parameter_mapping():
|
|
220
|
+
wave = _wave("real")
|
|
221
|
+
old = (np.array([[1., 0., 0., 1., 0., 0.]]), 0.)
|
|
222
|
+
with pytest.raises(TypeError, match="mapping"):
|
|
223
|
+
wave.filters = old
|
|
224
|
+
with pytest.raises(TypeError, match="mapping"):
|
|
225
|
+
wave.sample(filters=old)
|
|
@@ -6,6 +6,7 @@ from scipy.signal import butter, sosfilt
|
|
|
6
6
|
|
|
7
7
|
import waveforms as wf
|
|
8
8
|
from waveforms._waveform import quantize_samples, sosfilt_samples
|
|
9
|
+
from waveforms.waveform import _filter_and_finish
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def _clip(values, lower, upper):
|
|
@@ -148,17 +149,24 @@ def test_scipy_fallback(coefficient_dtype):
|
|
|
148
149
|
sos = butter(4, .3, output="sos").astype(coefficient_dtype)
|
|
149
150
|
if coefficient_dtype == np.complex128:
|
|
150
151
|
sos[0, 0] += .1j
|
|
151
|
-
wave.filters = sos, .03
|
|
152
152
|
raw = .4 + .5 * np.cos(6 * np.pi * np.arange(1024) / 1024)
|
|
153
153
|
expected = _clip(sosfilt(sos, raw - .03) + .03, wave.min, wave.max)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
154
|
+
result, _ = _filter_and_finish(
|
|
155
|
+
raw.copy(), sos, None, 1., None, wave.min, wave.max, initial=.03)
|
|
156
|
+
np.testing.assert_allclose(result, expected, rtol=3e-13, atol=3e-14)
|
|
157
|
+
chunks, state = [], None
|
|
158
|
+
for start in range(0, len(raw), 73):
|
|
159
|
+
result, state = _filter_and_finish(
|
|
160
|
+
raw[start:start + 73].copy(), sos, None, 1., None,
|
|
161
|
+
wave.min, wave.max, state, initial=.03)
|
|
162
|
+
chunks.append(result)
|
|
163
|
+
np.testing.assert_allclose(np.concatenate(chunks), expected,
|
|
164
|
+
rtol=3e-13, atol=3e-14)
|
|
157
165
|
|
|
158
166
|
|
|
159
|
-
def
|
|
167
|
+
def test_pipeline_output_casting_and_single_section():
|
|
160
168
|
wave = _wave()
|
|
161
|
-
wave.filters =
|
|
169
|
+
wave.filters = {.04: .2}
|
|
162
170
|
expected = wave.sample()
|
|
163
171
|
for dtype in (None, np.float32, np.float64):
|
|
164
172
|
for stride in (1, 2):
|
|
@@ -3,7 +3,7 @@ import pickle
|
|
|
3
3
|
import numpy as np
|
|
4
4
|
import pytest
|
|
5
5
|
from scipy.interpolate import PchipInterpolator
|
|
6
|
-
from scipy.signal import
|
|
6
|
+
from scipy.signal import sosfilt
|
|
7
7
|
|
|
8
8
|
import waveforms as wf
|
|
9
9
|
from waveforms._waveform import quantize_samples
|
|
@@ -129,14 +129,13 @@ def test_waveform_sampling_order_chunking_quantization_and_pickle():
|
|
|
129
129
|
[0.0, 0.0625, 0.25, 0.5625, 1.0],
|
|
130
130
|
table_size=257,
|
|
131
131
|
)
|
|
132
|
-
|
|
133
|
-
sos = tf2sos(b, a)
|
|
132
|
+
sos = wf.exp_decay_filter(.2, .04, sample_rate, inv=True, output="sos")
|
|
134
133
|
waveform = wf.t()
|
|
135
134
|
waveform.start = 0.0
|
|
136
135
|
waveform.stop = 1.0
|
|
137
136
|
waveform.sample_rate = sample_rate
|
|
138
137
|
waveform.nonlinear = mapping
|
|
139
|
-
waveform.filters =
|
|
138
|
+
waveform.filters = {.04: .2}
|
|
140
139
|
|
|
141
140
|
raw = np.arange(sample_rate, dtype=np.float64) / sample_rate
|
|
142
141
|
expected = sosfilt(sos, mapping(raw))
|