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.
Files changed (37) hide show
  1. {waveforms-3.4.0/waveforms.egg-info → waveforms-3.5.1}/PKG-INFO +66 -1
  2. {waveforms-3.4.0 → waveforms-3.5.1}/README.md +65 -0
  3. waveforms-3.5.1/tests/test_native_sos.py +176 -0
  4. waveforms-3.5.1/tests/test_nonlinear.py +255 -0
  5. waveforms-3.5.1/tests/test_output_limits.py +252 -0
  6. {waveforms-3.4.0 → waveforms-3.5.1}/tests/test_waveform.py +1 -1
  7. waveforms-3.5.1/waveforms/WaveformLexer.py +134 -0
  8. waveforms-3.5.1/waveforms/WaveformListener.py +228 -0
  9. waveforms-3.5.1/waveforms/WaveformParser.py +1241 -0
  10. {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/__init__.py +2 -1
  11. {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/_cwaveform.c +970 -19
  12. {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/_cwaveform.h +76 -1
  13. waveforms-3.5.1/waveforms/_cwaveform.md +157 -0
  14. {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/_waveform.pyi +35 -1
  15. {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/_waveform.pyx +314 -2
  16. {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/distortion.py +166 -10
  17. waveforms-3.5.1/waveforms/nonlinear.py +353 -0
  18. {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/version.py +1 -1
  19. {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/waveform.py +283 -87
  20. {waveforms-3.4.0 → waveforms-3.5.1/waveforms.egg-info}/PKG-INFO +66 -1
  21. {waveforms-3.4.0 → waveforms-3.5.1}/waveforms.egg-info/SOURCES.txt +7 -0
  22. waveforms-3.4.0/waveforms/_cwaveform.md +0 -85
  23. {waveforms-3.4.0 → waveforms-3.5.1}/LICENSE +0 -0
  24. {waveforms-3.4.0 → waveforms-3.5.1}/MANIFEST.in +0 -0
  25. {waveforms-3.4.0 → waveforms-3.5.1}/pyproject.toml +0 -0
  26. {waveforms-3.4.0 → waveforms-3.5.1}/setup.cfg +0 -0
  27. {waveforms-3.4.0 → waveforms-3.5.1}/setup.py +0 -0
  28. {waveforms-3.4.0 → waveforms-3.5.1}/tests/test_core.py +0 -0
  29. {waveforms-3.4.0 → waveforms-3.5.1}/tests/test_wavevstack.py +0 -0
  30. {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/Waveform.g4 +0 -0
  31. {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/__main__.py +0 -0
  32. {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/utils.py +0 -0
  33. {waveforms-3.4.0 → waveforms-3.5.1}/waveforms/waveform_parser.py +0 -0
  34. {waveforms-3.4.0 → waveforms-3.5.1}/waveforms.egg-info/dependency_links.txt +0 -0
  35. {waveforms-3.4.0 → waveforms-3.5.1}/waveforms.egg-info/entry_points.txt +0 -0
  36. {waveforms-3.4.0 → waveforms-3.5.1}/waveforms.egg-info/requires.txt +0 -0
  37. {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.4.0
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
+ )