waveforms 3.6.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.6.1/waveforms.egg-info → waveforms-3.7.0}/PKG-INFO +29 -6
  2. {waveforms-3.6.1 → waveforms-3.7.0}/README.md +28 -5
  3. waveforms-3.7.0/tests/test_filter_parameters.py +225 -0
  4. {waveforms-3.6.1 → waveforms-3.7.0}/tests/test_native_sos.py +14 -6
  5. {waveforms-3.6.1 → waveforms-3.7.0}/tests/test_nonlinear.py +3 -4
  6. {waveforms-3.6.1 → waveforms-3.7.0}/tests/test_output_limits.py +15 -9
  7. {waveforms-3.6.1 → waveforms-3.7.0}/tests/test_waveform.py +5 -7
  8. {waveforms-3.6.1 → waveforms-3.7.0}/tests/test_wavevstack.py +5 -8
  9. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/version.py +1 -1
  10. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/waveform.py +104 -42
  11. {waveforms-3.6.1 → waveforms-3.7.0/waveforms.egg-info}/PKG-INFO +29 -6
  12. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms.egg-info/SOURCES.txt +1 -0
  13. {waveforms-3.6.1 → waveforms-3.7.0}/LICENSE +0 -0
  14. {waveforms-3.6.1 → waveforms-3.7.0}/MANIFEST.in +0 -0
  15. {waveforms-3.6.1 → waveforms-3.7.0}/pyproject.toml +0 -0
  16. {waveforms-3.6.1 → waveforms-3.7.0}/setup.cfg +0 -0
  17. {waveforms-3.6.1 → waveforms-3.7.0}/setup.py +0 -0
  18. {waveforms-3.6.1 → waveforms-3.7.0}/tests/test_common_pipeline.py +0 -0
  19. {waveforms-3.6.1 → waveforms-3.7.0}/tests/test_core.py +0 -0
  20. {waveforms-3.6.1 → waveforms-3.7.0}/tests/test_simd_sampling.py +0 -0
  21. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/Waveform.g4 +0 -0
  22. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/WaveformLexer.py +0 -0
  23. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/WaveformListener.py +0 -0
  24. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/WaveformParser.py +0 -0
  25. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/__init__.py +0 -0
  26. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/__main__.py +0 -0
  27. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/_cwaveform.c +0 -0
  28. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/_cwaveform.h +0 -0
  29. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/_cwaveform.md +0 -0
  30. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/_waveform.pyi +0 -0
  31. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/_waveform.pyx +0 -0
  32. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/distortion.py +0 -0
  33. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/nonlinear.py +0 -0
  34. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/utils.py +0 -0
  35. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms/waveform_parser.py +0 -0
  36. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms.egg-info/dependency_links.txt +0 -0
  37. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms.egg-info/entry_points.txt +0 -0
  38. {waveforms-3.6.1 → waveforms-3.7.0}/waveforms.egg-info/requires.txt +0 -0
  39. {waveforms-3.6.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.6.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,14 +202,37 @@ 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
 
@@ -158,14 +158,37 @@ 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
 
@@ -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))
@@ -49,14 +49,13 @@ def _clip(values, lower, upper):
49
49
 
50
50
  @pytest.mark.parametrize("kind", ["real", "stack", "complex", "complex_stack"])
51
51
  @pytest.mark.parametrize("rate", [128, 127.5])
52
- @pytest.mark.parametrize("gain", [0.25, 2.0])
53
- def test_output_limits_follow_mapping_and_filter_with_chunk_state(kind, rate, gain):
52
+ @pytest.mark.parametrize("amp", [0.25, -0.5])
53
+ def test_output_limits_follow_mapping_and_filter_with_chunk_state(kind, rate, amp):
54
54
  wave = _configure(_signal(kind), rate)
55
55
  wave.min, wave.max = -0.22, 0.27
56
56
  # Stateful filter exercises overshoot and continuing with the unclipped zf.
57
- sos = np.array([[gain * 0.5, 0., 0., 1., -0.5, 0.]])
58
- initial = 0.03
59
- wave.filters = (sos, initial)
57
+ sos = wf.exp_decay_filter(amp, .04, rate, inv=True, output="sos")
58
+ wave.filters = {.04: amp}
60
59
  mapping = wf.NonlinearMap.from_samples(
61
60
  [-3., 3.], [-6., 6.], method="linear", table_size=2)
62
61
  wave.nonlinear = (mapping, mapping) if "complex" in kind else mapping
@@ -65,6 +64,7 @@ def test_output_limits_follow_mapping_and_filter_with_chunk_state(kind, rate, ga
65
64
  raw = 0.45 + 0.35 * np.cos(6 * np.pi * positions)
66
65
  if "complex" in kind:
67
66
  raw = raw + 1j * (-0.3 + 0.2 * np.sin(4 * np.pi * positions))
67
+ initial = 2 * raw[0]
68
68
  filtered = sosfilt(sos, 2 * raw - initial) + initial
69
69
  expected = _clip(filtered, wave.min, wave.max)
70
70
 
@@ -103,11 +103,17 @@ def test_output_limits_follow_mapping_and_filter_with_chunk_state(kind, rate, ga
103
103
  np.testing.assert_array_equal(np.concatenate(list(chunks)), expected_integer)
104
104
 
105
105
 
106
- @pytest.mark.parametrize("gain,expected", [(2., .2), (.25, .1)])
107
- def test_filter_gain_never_sees_prematurely_limited_input(gain, expected):
108
- wave = _configure(wf.const(.4))
106
+ @pytest.mark.parametrize("amp", [.5, -.5])
107
+ def test_filter_never_sees_prematurely_limited_input(amp):
108
+ wave = _configure(.4 - .35 * (wf.step(0) >> .25))
109
109
  wave.min, wave.max = -.2, .2
110
- wave.filters = (np.array([[gain, 0., 0., 1., 0., 0.]]), 0.)
110
+ wave.filters = {.03: amp}
111
+ sos = wf.exp_decay_filter(amp, .03, wave.sample_rate, inv=True, output="sos")
112
+ raw = np.where(np.arange(128) < 32, .4, .05)
113
+ expected = np.clip(sosfilt(sos, raw - raw[0]) + raw[0], -.2, .2)
114
+ limited = np.clip(raw, -.2, .2)
115
+ premature = np.clip(sosfilt(sos, limited - limited[0]) + limited[0], -.2, .2)
116
+ assert not np.allclose(expected, premature)
111
117
  np.testing.assert_allclose(wave.sample(), expected)
112
118
  np.testing.assert_allclose(np.concatenate(list(wave.sample(chunk_size=7))), expected)
113
119
 
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
  import numpy as np
7
7
  import pytest
8
8
  import scipy.special as special
9
- from scipy.signal import butter, lfilter, lfiltic, tf2sos
9
+ from scipy.signal import sosfilt
10
10
 
11
11
  import waveforms as wf
12
12
  from waveforms._waveform import (
@@ -252,16 +252,15 @@ def test_multi_frequency_drag_matches_120ghz_tick_behavior():
252
252
 
253
253
  def test_filters_and_chunked_sampling():
254
254
  sample_rate = 1000
255
- b, a = butter(3, 4.0, "lowpass", fs=sample_rate)
256
- zi = lfiltic(b, a, [0])
255
+ sos = wf.exp_decay_filter(.2, .04, sample_rate, inv=True, output="sos")
257
256
  x = np.linspace(-1, 1, 2000, endpoint=False)
258
257
 
259
258
  wav = wf.step(0)
260
259
  wav.start = -1
261
260
  wav.stop = 1
262
261
  wav.sample_rate = sample_rate
263
- wav.filters = (tf2sos(b, a), 0)
264
- expected = lfilter(b, a, np.heaviside(x, 1), zi=zi)[0]
262
+ wav.filters = {.04: .2}
263
+ expected = sosfilt(sos, np.heaviside(x, 1))
265
264
  assert np.allclose(wav.sample(), expected)
266
265
  assert np.allclose(np.concatenate(list(wav.sample(chunk_size=137))), expected)
267
266
 
@@ -575,8 +574,7 @@ def test_integer_sampling_filtered_and_overlapping_fallbacks_are_exact():
575
574
  quantize_samples(overlapping.sample(rate), 16),
576
575
  )
577
576
 
578
- b, a = butter(3, 50e6, "lowpass", fs=rate)
579
- overlapping.filters = (tf2sos(b, a), 0)
577
+ overlapping.filters = {20e-9: .2}
580
578
  assert np.array_equal(
581
579
  overlapping.sample(rate, dtype=np.int16),
582
580
  quantize_samples(overlapping.sample(rate), 16),
@@ -1,10 +1,10 @@
1
1
  import pickle
2
2
 
3
3
  import numpy as np
4
- from scipy.signal import butter, lfilter, lfiltic, tf2sos
4
+ from scipy.signal import sosfilt
5
5
 
6
6
  from waveforms import (
7
- Waveform, WaveVStack, cos, gaussian, pi, poly, sin, step,
7
+ Waveform, WaveVStack, cos, exp_decay_filter, gaussian, pi, poly, sin, step,
8
8
  time_to_tick, zero,
9
9
  )
10
10
 
@@ -35,18 +35,15 @@ def test_wavevstack_sampling_algebra_and_shifts():
35
35
 
36
36
  def test_wavevstack_filtering_and_chunked_sampling():
37
37
  sample_rate = 1000
38
- b, a = butter(3, 4.0, "lowpass", fs=sample_rate)
39
- zi = lfiltic(b, a, [0])
38
+ sos = exp_decay_filter(.2, .04, sample_rate, inv=True, output="sos")
40
39
  x = np.linspace(-1, 1, 2000, endpoint=False)
41
40
 
42
41
  stack = WaveVStack([step(0) << 0.5, -step(0)])
43
42
  stack.sample_rate = sample_rate
44
43
  stack.start = -1
45
44
  stack.stop = 1
46
- stack.filters = (tf2sos(b, a), 0)
47
- expected = lfilter(
48
- b, a, np.heaviside(x + 0.5, 1) - np.heaviside(x, 1), zi=zi
49
- )[0]
45
+ stack.filters = {.04: .2}
46
+ expected = sosfilt(sos, np.heaviside(x + 0.5, 1) - np.heaviside(x, 1))
50
47
 
51
48
  assert np.allclose(stack.sample(), expected, atol=1e-6)
52
49
  chunks = np.concatenate(list(stack.sample(chunk_size=137)))
@@ -1,2 +1,2 @@
1
1
  """Define version number here and read it from setup.py automatically"""
2
- __version__ = "3.6.1"
2
+ __version__ = "3.7.0"
@@ -9,6 +9,8 @@ from __future__ import annotations
9
9
 
10
10
  import io
11
11
  import struct
12
+ from collections import defaultdict
13
+ from collections.abc import Mapping
12
14
  from functools import lru_cache
13
15
  from typing import Iterable, cast
14
16
 
@@ -16,6 +18,7 @@ import numpy as np
16
18
  from numpy import e, inf, pi
17
19
  from scipy.signal import sosfilt
18
20
 
21
+ from .distortion import exp_decay_filter, exp_decay_filter_from_cascade
19
22
  from .nonlinear import NonlinearMap
20
23
 
21
24
  from ._waveform import (
@@ -102,7 +105,7 @@ def _copy_sampling_metadata(source, target):
102
105
  target.start = source.start
103
106
  target.stop = source.stop
104
107
  target.sample_rate = source.sample_rate
105
- target.filters = source.filters
108
+ target.filters = source._filters
106
109
  target.nonlinear = source.nonlinear
107
110
  target.label = source.label
108
111
  return target
@@ -332,10 +335,38 @@ def _real_sampling_output(dtype, bits, out):
332
335
  return None
333
336
 
334
337
 
335
- def _filter_samples(sig, filters, zi=None):
338
+ @lru_cache(maxsize=128)
339
+ def _exp_decay_sos(stages, sample_rate):
340
+ """Compile a snapshot of cascade parameters for one sampling clock."""
341
+ if not np.isfinite(sample_rate) or sample_rate <= 0:
342
+ raise ValueError("sample_rate must be a finite positive number")
343
+ amp, tau = exp_decay_filter_from_cascade(
344
+ [(amp, tau) for tau, amp in stages])
345
+ sos = np.ascontiguousarray(
346
+ exp_decay_filter(amp, tau, sample_rate, inv=True, output='sos'),
347
+ dtype=np.float64)
348
+ if not np.all(np.isfinite(sos)):
349
+ raise ValueError("filters produce non-finite SOS coefficients")
350
+ # Cache coefficients, never mutable streaming state or a signal's baseline.
351
+ sos.flags.writeable = False
352
+ return sos
353
+
354
+
355
+ def _prepare_filters(filters, sample_rate):
336
356
  if filters is None:
337
- return sig, zi
338
- sos, initial = filters
357
+ return None
358
+ if not isinstance(filters, Mapping):
359
+ raise TypeError("filters must be a mapping from tau (seconds) to amp")
360
+ if not filters:
361
+ return None
362
+ # Ignore missing-key/default-zero entries and key the cache by values, so
363
+ # edits through filters[tau] += amp take effect on the next sampling call.
364
+ stages = tuple(sorted((float(tau), float(amp))
365
+ for tau, amp in filters.items() if amp != 0))
366
+ return _exp_decay_sos(stages, float(sample_rate)) if stages else None
367
+
368
+
369
+ def _filter_samples(sig, sos, initial, zi=None):
339
370
  sos = np.asarray(sos)
340
371
  if not sos.flags.writeable:
341
372
  sos = sos.copy()
@@ -349,20 +380,20 @@ def _filter_samples(sig, filters, zi=None):
349
380
  return filtered, zi
350
381
 
351
382
 
352
- def _filter_and_finish(sig, filters, dtype, full_scale, out,
353
- minimum, maximum, zi=None):
354
- if filters is None:
383
+ def _filter_and_finish(sig, sos, dtype, full_scale, out,
384
+ minimum, maximum, zi=None, initial=None):
385
+ if sos is None:
355
386
  return _finish_samples(
356
387
  sig, dtype, full_scale, out, minimum, maximum), zi
357
- sos, initial = filters
358
388
  sos = np.asarray(sos)
359
- initial = initial if initial else 0.0
389
+ if initial is None:
390
+ initial = sig[0] if len(sig) else 0.0
360
391
  values_dtype = np.result_type(sig, np.asarray(initial), sos,
361
392
  zi if zi is not None else np.float64)
362
393
  # Preserve SciPy's complex-coefficient and extended-precision behavior.
363
394
  if (sos.dtype.kind not in 'biuf' or sos.dtype.itemsize > 8
364
395
  or values_dtype not in (np.dtype(np.float64), np.dtype(np.complex128))):
365
- sig, zi = _filter_samples(sig, filters, zi)
396
+ sig, zi = _filter_samples(sig, sos, initial, zi)
366
397
  return _finish_samples(
367
398
  sig, dtype, full_scale, out, minimum, maximum), zi
368
399
  dtype, bits = _quantization_bits(dtype, out)
@@ -479,11 +510,32 @@ class Waveform(metaclass=_WaveformMeta):
479
510
  start: float | None
480
511
  stop: float | None
481
512
  sample_rate: float | None
482
- filters: tuple[np.ndarray, float] | None
513
+ _filters: defaultdict[float, float] | None
483
514
  nonlinear: NonlinearMap | tuple[NonlinearMap | None, NonlinearMap | None] | None
484
515
  min: float
485
516
  max: float
486
517
 
518
+ @property
519
+ def filters(self) -> defaultdict[float, float]:
520
+ """Exponential cascade stages: time constant (seconds) -> amplitude.
521
+
522
+ Missing amplitudes default to zero. SOS coefficients are derived from
523
+ these parameters at sampling time using the actual sample rate and
524
+ ``inv=True`` (predistortion).
525
+ """
526
+ # Most temporary expression nodes never use filters. Allocate only on
527
+ # access, keeping pulse construction and unfiltered sampling cheap.
528
+ if self._filters is None:
529
+ self._filters = defaultdict(float)
530
+ return self._filters
531
+
532
+ @filters.setter
533
+ def filters(self, value: Mapping[float, float] | None):
534
+ if value is not None and not isinstance(value, Mapping):
535
+ raise TypeError("filters must be a mapping from tau (seconds) to amp")
536
+ # Copy on assignment: metadata on shifted/copied objects is independent.
537
+ self._filters = None if not value else defaultdict(float, value)
538
+
487
539
  def _evaluate_raw(self, x):
488
540
  """Evaluate before output limits, nonlinear calibration or filtering."""
489
541
  return self(x, _raw=True)
@@ -508,8 +560,15 @@ class Waveform(metaclass=_WaveformMeta):
508
560
 
509
561
  def sample(self, sample_rate=None, out: np.ndarray | None = None,
510
562
  chunk_size=None, function_lib=None,
511
- filters: tuple[np.ndarray, float] | None = None,
563
+ filters: Mapping[float, float] | None = None,
512
564
  dtype=None, full_scale=1.0, nonlinear=None):
565
+ """Sample, map, filter, limit, then convert to the requested dtype.
566
+
567
+ ``filters=None`` uses this object's tau-to-amp cascade; ``filters={}``
568
+ disables it for this call. Filtering assumes the mapped first sample
569
+ was held indefinitely before playback. The baseline and filter state
570
+ persist across chunks of this sampling call.
571
+ """
513
572
  minimum, maximum = _amplitude_limits(self)
514
573
  if function_lib is not None:
515
574
  raise NotImplementedError("custom waveform functions are not supported")
@@ -520,14 +579,14 @@ class Waveform(metaclass=_WaveformMeta):
520
579
  f"Waveform is not initialized. {self.start=}, {self.stop=}, "
521
580
  f"{sample_rate=}"
522
581
  )
523
- if filters is None:
524
- filters = self.filters
582
+ sos = _prepare_filters(self._filters if filters is None else filters,
583
+ sample_rate)
525
584
  if nonlinear is None:
526
585
  nonlinear = self.nonlinear
527
586
  dtype, bits = _quantization_bits(dtype, out)
528
587
  if chunk_size is not None:
529
588
  return self._sample_iter(
530
- sample_rate, int(chunk_size), out, filters, dtype, full_scale,
589
+ sample_rate, int(chunk_size), out, sos, dtype, full_scale,
531
590
  nonlinear,
532
591
  )
533
592
 
@@ -536,7 +595,7 @@ class Waveform(metaclass=_WaveformMeta):
536
595
  x = np.arange(self.start, self.stop, 1 / float(sample_rate))
537
596
  sig = cast(np.ndarray, self._evaluate_raw(x))
538
597
  else:
539
- if bits is not None and filters is None and nonlinear is None:
598
+ if bits is not None and sos is None and nonlinear is None:
540
599
  specialized = getattr(
541
600
  self, "_sample_supported_quantized", None
542
601
  )
@@ -549,7 +608,7 @@ class Waveform(metaclass=_WaveformMeta):
549
608
  sig = self._sample_supported(*plan)
550
609
  sig = _apply_nonlinear(sig, nonlinear)
551
610
  result, _ = _filter_and_finish(
552
- sig, filters, dtype, full_scale, out, minimum, maximum)
611
+ sig, sos, dtype, full_scale, out, minimum, maximum)
553
612
  return cast(np.ndarray, result)
554
613
 
555
614
  def _sample_supported(self, start_tick, count, step_numerator,
@@ -558,13 +617,14 @@ class Waveform(metaclass=_WaveformMeta):
558
617
  index_offset)
559
618
  return cast(np.ndarray, self._evaluate_raw(x))
560
619
 
561
- def _sample_iter(self, sample_rate, chunk_size, out, filters, dtype,
620
+ def _sample_iter(self, sample_rate, chunk_size, out, sos, dtype,
562
621
  full_scale, nonlinear):
563
622
  minimum, maximum = _amplitude_limits(self)
564
623
  start = cast(float, self.start)
565
624
  stop = cast(float, self.stop)
566
625
  output_index = 0
567
626
  zi = None
627
+ initial = None
568
628
  if chunk_size <= 0:
569
629
  raise ValueError("chunk_size must be positive")
570
630
  plan = _sampling_plan(start, stop, sample_rate)
@@ -596,8 +656,10 @@ class Waveform(metaclass=_WaveformMeta):
596
656
  target = (None if output is None
597
657
  else output[output_index:output_index + size])
598
658
  sig = _apply_nonlinear(sig, nonlinear)
659
+ if initial is None and sos is not None:
660
+ initial = sig[0]
599
661
  sig, zi = _filter_and_finish(
600
- sig, filters, dtype, full_scale, target, minimum, maximum, zi)
662
+ sig, sos, dtype, full_scale, target, minimum, maximum, zi, initial)
601
663
  yield sig
602
664
  output_index += size
603
665
 
@@ -638,7 +700,7 @@ class _RealWaveformBase(Waveform):
638
700
 
639
701
  __slots__ = (
640
702
  "_core", "max", "min", "start", "stop", "sample_rate",
641
- "filters", "nonlinear", "label",
703
+ "_filters", "nonlinear", "label",
642
704
  )
643
705
 
644
706
 
@@ -647,7 +709,7 @@ class ComplexWaveform(Waveform):
647
709
 
648
710
  __slots__ = (
649
711
  "_real", "_imag", "max", "min", "start", "stop", "sample_rate",
650
- "filters", "nonlinear", "label",
712
+ "_filters", "nonlinear", "label",
651
713
  )
652
714
 
653
715
  def __init__(self, real=0.0, imag=0.0):
@@ -681,7 +743,7 @@ class ComplexWaveform(Waveform):
681
743
  self.start = None
682
744
  self.stop = None
683
745
  self.sample_rate = None
684
- self.filters = None
746
+ self._filters = None
685
747
  self.nonlinear = None
686
748
  self.label = None
687
749
 
@@ -913,7 +975,7 @@ class ComplexWaveform(Waveform):
913
975
 
914
976
  def __getstate__(self):
915
977
  return (self.to_bytes(), self.max, self.min, self.start, self.stop,
916
- self.sample_rate, self.filters, self.nonlinear, self.label)
978
+ self.sample_rate, self._filters, self.nonlinear, self.label)
917
979
 
918
980
  def __setstate__(self, state):
919
981
  if len(state) == 8:
@@ -965,7 +1027,7 @@ class _RealWaveVStackBase(WaveVStack):
965
1027
  """Common real-stack type; concrete storage is provided by the C core."""
966
1028
 
967
1029
  __slots__ = (
968
- "start", "stop", "sample_rate", "offset", "filters", "nonlinear", "label",
1030
+ "start", "stop", "sample_rate", "offset", "_filters", "nonlinear", "label",
969
1031
  "function_lib", "_sample_plan_cache", "min", "max",
970
1032
  )
971
1033
 
@@ -975,7 +1037,7 @@ class ComplexWaveVStack(WaveVStack):
975
1037
 
976
1038
  __slots__ = (
977
1039
  "_real_stack", "_imag_stack", "start", "stop", "sample_rate",
978
- "offset", "shift", "filters", "nonlinear", "label", "function_lib",
1040
+ "offset", "shift", "_filters", "nonlinear", "label", "function_lib",
979
1041
  "min", "max",
980
1042
  )
981
1043
 
@@ -1021,7 +1083,7 @@ class ComplexWaveVStack(WaveVStack):
1021
1083
  self.start = None
1022
1084
  self.stop = None
1023
1085
  self.sample_rate = None
1024
- self.filters = None
1086
+ self._filters = None
1025
1087
  self.nonlinear = None
1026
1088
  self.label = None
1027
1089
  self.function_lib = None
@@ -1177,7 +1239,7 @@ class ComplexWaveVStack(WaveVStack):
1177
1239
  else:
1178
1240
  real, imag = _number_parts(other)
1179
1241
  result = ComplexWaveVStack(self.real + real, self.imag + imag)
1180
- result.filters = self.filters
1242
+ result.filters = self._filters
1181
1243
  result.label = self.label
1182
1244
  return result
1183
1245
 
@@ -1209,7 +1271,7 @@ class ComplexWaveVStack(WaveVStack):
1209
1271
  self.real * real - self.imag * imag,
1210
1272
  self.real * imag + self.imag * real,
1211
1273
  )
1212
- result.filters = self.filters
1274
+ result.filters = self._filters
1213
1275
  result.label = self.label
1214
1276
  return result
1215
1277
 
@@ -1265,7 +1327,7 @@ class ComplexWaveVStack(WaveVStack):
1265
1327
 
1266
1328
  def __getstate__(self):
1267
1329
  return (self.to_bytes(), self.start, self.stop, self.sample_rate,
1268
- self.filters, self.nonlinear, self.label, self.min, self.max)
1330
+ self._filters, self.nonlinear, self.label, self.min, self.max)
1269
1331
 
1270
1332
  def __setstate__(self, state):
1271
1333
  self.min, self.max = state[7:] if len(state) == 9 else (-inf, inf)
@@ -1333,7 +1395,7 @@ class RealWaveform(_RealWaveformBase):
1333
1395
  self.start = None
1334
1396
  self.stop = None
1335
1397
  self.sample_rate = None
1336
- self.filters = None
1398
+ self._filters = None
1337
1399
  self.nonlinear = None
1338
1400
  self.label = None
1339
1401
 
@@ -1447,8 +1509,8 @@ class RealWaveform(_RealWaveformBase):
1447
1509
  sample_rate = self.sample_rate
1448
1510
  if self.start is None or self.stop is None or sample_rate is None:
1449
1511
  raise ValueError("RealWaveform sampling metadata is incomplete")
1450
- if filters is None:
1451
- filters = self.filters
1512
+ sos = _prepare_filters(self._filters if filters is None else filters,
1513
+ sample_rate)
1452
1514
  if nonlinear is None:
1453
1515
  nonlinear = self.nonlinear
1454
1516
  plan = _sampling_plan(self.start, self.stop, sample_rate)
@@ -1459,7 +1521,7 @@ class RealWaveform(_RealWaveformBase):
1459
1521
  values = self._evaluate_raw(positions)
1460
1522
  else:
1461
1523
  start_tick, count, step_numerator, step_denominator = plan
1462
- if filters is None and nonlinear is None:
1524
+ if sos is None and nonlinear is None:
1463
1525
  values = self._core.sample(
1464
1526
  start_tick, count, step_numerator, step_denominator,
1465
1527
  self._delay_tick, self._scale, minimum, maximum,
@@ -1473,7 +1535,7 @@ class RealWaveform(_RealWaveformBase):
1473
1535
  )
1474
1536
  values = _apply_nonlinear(values, nonlinear)
1475
1537
  result, _ = _filter_and_finish(
1476
- values, filters, dtype, full_scale, out, minimum, maximum)
1538
+ values, sos, dtype, full_scale, out, minimum, maximum)
1477
1539
  return result
1478
1540
 
1479
1541
  def to_bytes(self):
@@ -1624,7 +1686,7 @@ class RealWaveform(_RealWaveformBase):
1624
1686
 
1625
1687
  def __getstate__(self):
1626
1688
  return (self.to_bytes(), self.max, self.min, self.start, self.stop,
1627
- self.sample_rate, self.filters, self.nonlinear, self.label)
1689
+ self.sample_rate, self._filters, self.nonlinear, self.label)
1628
1690
 
1629
1691
  def __setstate__(self, state):
1630
1692
  _ensure_c_clock_locked()
@@ -1716,7 +1778,7 @@ class RealWaveVStack(_RealWaveVStackBase):
1716
1778
  self.sample_rate = None
1717
1779
  self.offset = 0.0
1718
1780
  self._shift_tick = 0
1719
- self.filters = None
1781
+ self._filters = None
1720
1782
  self.nonlinear = None
1721
1783
  self.label = None
1722
1784
  self.function_lib = None
@@ -1831,8 +1893,8 @@ class RealWaveVStack(_RealWaveVStackBase):
1831
1893
  sample_rate = self.sample_rate
1832
1894
  if self.start is None or self.stop is None or sample_rate is None:
1833
1895
  raise ValueError("RealWaveVStack sampling metadata is incomplete")
1834
- if filters is None:
1835
- filters = self.filters
1896
+ sos = _prepare_filters(self._filters if filters is None else filters,
1897
+ sample_rate)
1836
1898
  if nonlinear is None:
1837
1899
  nonlinear = self.nonlinear
1838
1900
  plan = _sampling_plan(self.start, self.stop, sample_rate)
@@ -1856,7 +1918,7 @@ class RealWaveVStack(_RealWaveVStackBase):
1856
1918
  )
1857
1919
  self._sample_plan_cache = cache_key, sample_plan
1858
1920
  if sample_plan is not None:
1859
- if filters is None and nonlinear is None:
1921
+ if sos is None and nonlinear is None:
1860
1922
  values = sample_plan.sample(
1861
1923
  self.offset, bits or 0, full_scale,
1862
1924
  _real_sampling_output(dtype, bits, out), minimum, maximum,
@@ -1865,7 +1927,7 @@ class RealWaveVStack(_RealWaveVStackBase):
1865
1927
  _finish_samples(values, dtype, full_scale, out))
1866
1928
  values = sample_plan.sample(self.offset)
1867
1929
  else:
1868
- if filters is None and nonlinear is None:
1930
+ if sos is None and nonlinear is None:
1869
1931
  values = self._core.sample(
1870
1932
  start_tick, count, step_numerator, step_denominator,
1871
1933
  self._shift_tick, self.offset, bits or 0, full_scale,
@@ -1882,7 +1944,7 @@ class RealWaveVStack(_RealWaveVStackBase):
1882
1944
  )
1883
1945
  values = _apply_nonlinear(values, nonlinear)
1884
1946
  result, _ = _filter_and_finish(
1885
- values, filters, dtype, full_scale, out, minimum, maximum)
1947
+ values, sos, dtype, full_scale, out, minimum, maximum)
1886
1948
  return result
1887
1949
 
1888
1950
  def simplify(self, eps=1e-15):
@@ -2023,7 +2085,7 @@ class RealWaveVStack(_RealWaveVStackBase):
2023
2085
  def __getstate__(self):
2024
2086
  return (self._core.to_bytes(), self.start, self.stop,
2025
2087
  self.sample_rate, self.offset, self._shift_tick,
2026
- self.filters, self.nonlinear, self.label, self.min, self.max)
2088
+ self._filters, self.nonlinear, self.label, self.min, self.max)
2027
2089
 
2028
2090
  def __setstate__(self, state):
2029
2091
  _ensure_c_clock_locked()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: waveforms
3
- Version: 3.6.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,14 +202,37 @@ 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
 
@@ -5,6 +5,7 @@ pyproject.toml
5
5
  setup.py
6
6
  tests/test_common_pipeline.py
7
7
  tests/test_core.py
8
+ tests/test_filter_parameters.py
8
9
  tests/test_native_sos.py
9
10
  tests/test_nonlinear.py
10
11
  tests/test_output_limits.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes