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.
Files changed (39) hide show
  1. {waveforms-3.5.1/waveforms.egg-info → waveforms-3.7.0}/PKG-INFO +40 -10
  2. {waveforms-3.5.1 → waveforms-3.7.0}/README.md +39 -9
  3. waveforms-3.7.0/tests/test_common_pipeline.py +242 -0
  4. waveforms-3.7.0/tests/test_filter_parameters.py +225 -0
  5. {waveforms-3.5.1 → waveforms-3.7.0}/tests/test_native_sos.py +14 -6
  6. {waveforms-3.5.1 → waveforms-3.7.0}/tests/test_nonlinear.py +3 -4
  7. {waveforms-3.5.1 → waveforms-3.7.0}/tests/test_output_limits.py +15 -9
  8. waveforms-3.7.0/tests/test_simd_sampling.py +136 -0
  9. {waveforms-3.5.1 → waveforms-3.7.0}/tests/test_waveform.py +5 -7
  10. {waveforms-3.5.1 → waveforms-3.7.0}/tests/test_wavevstack.py +5 -8
  11. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/__init__.py +2 -0
  12. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/_cwaveform.c +374 -175
  13. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/_cwaveform.h +2 -0
  14. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/_cwaveform.md +12 -0
  15. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/distortion.py +26 -1
  16. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/version.py +1 -1
  17. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/waveform.py +222 -116
  18. {waveforms-3.5.1 → waveforms-3.7.0/waveforms.egg-info}/PKG-INFO +40 -10
  19. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms.egg-info/SOURCES.txt +3 -0
  20. {waveforms-3.5.1 → waveforms-3.7.0}/LICENSE +0 -0
  21. {waveforms-3.5.1 → waveforms-3.7.0}/MANIFEST.in +0 -0
  22. {waveforms-3.5.1 → waveforms-3.7.0}/pyproject.toml +0 -0
  23. {waveforms-3.5.1 → waveforms-3.7.0}/setup.cfg +0 -0
  24. {waveforms-3.5.1 → waveforms-3.7.0}/setup.py +0 -0
  25. {waveforms-3.5.1 → waveforms-3.7.0}/tests/test_core.py +0 -0
  26. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/Waveform.g4 +0 -0
  27. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/WaveformLexer.py +0 -0
  28. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/WaveformListener.py +0 -0
  29. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/WaveformParser.py +0 -0
  30. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/__main__.py +0 -0
  31. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/_waveform.pyi +0 -0
  32. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/_waveform.pyx +0 -0
  33. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/nonlinear.py +0 -0
  34. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/utils.py +0 -0
  35. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms/waveform_parser.py +0 -0
  36. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms.egg-info/dependency_links.txt +0 -0
  37. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms.egg-info/entry_points.txt +0 -0
  38. {waveforms-3.5.1 → waveforms-3.7.0}/waveforms.egg-info/requires.txt +0 -0
  39. {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.5.1
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
- 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.
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. Complex
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)` 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.
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
- 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.
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. Complex
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)` 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.
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
- 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)
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 test_pipeline_output_casting_and_single_section_vector():
167
+ def test_pipeline_output_casting_and_single_section():
160
168
  wave = _wave()
161
- wave.filters = butter(2, .3, output="sos")[0], .03
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 butter, sosfilt, tf2sos
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
- b, a = butter(3, 40.0, "lowpass", fs=sample_rate)
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 = (sos, 0.0)
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))