Skip to content

wfm / wfmgen validation findings

This document records what the exhaustive validation pass (src/doppler/wfm/tests/test_dsp_correctness.py, src/doppler/wfm/tests/test_api_surface.py) uncovered. Each bug maps 1:1 to an @pytest.mark.xfail(reason="…#anchor", strict=True) in the suite, so the tests stay green while the gap is tracked; strict=True means a later fix that makes the check pass will fail CI until the marker is removed (forcing the follow-up PR to clean up). Behaviour notes are not bugs — they document surprising-but-intended behaviour that the validation clarified, and feed the documentation gap-closure (Part C).

Per the agreed policy for this pass: document & file only — no C/binding fixes here. Fixes land in a follow-up PR.


Bugs

pn-default-poly — PN() default polynomial is degenerate

✅ Resolved in #196 (closes #191) — PN() with poly omitted/0 now auto-selects the MLS primitive polynomial, matching Synth(pn_poly=0). The xfail is removed; TestPN::test_default_poly_is_maximal_length is now a positive regression test.

Symbol: doppler.wfm.PN Severity: medium (silent wrong output — a non-maximal sequence) xfail: TestPN::test_default_poly_is_maximal_length

Expected. wfm.pyi documents poly: int = 96 as the constructor default, and PN's own docstring/doctest shows a maximal-length sequence for length=7 (chips[:8] == [1,0,0,0,0,0,1,1], 64 ones per period 127).

Observed. PN(seed=1, length=7).generate(127) (poly omitted) returns a degenerate sequence: a single 1 followed by all zeros (sum = 1), i.e. the LFSR runs with no feedback (effective poly = 0).

>>> import doppler.wfm as w
>>> int(w.PN(seed=1, length=7).generate(127).sum())        # omitted poly
1
>>> int(w.PN(poly=96, seed=1, length=7).generate(127).sum())          # works
64
>>> int(w.PN(poly=w.mls_poly(7), seed=1, length=7).generate(127).sum())  # works
64
>>> int(w.PN(poly=0, seed=1, length=7).generate(127).sum())  # confirms poly=0
1

Suspected root cause. The binding default for poly (when the kwarg is absent) is 0, not 96, and the standalone PN core treats poly = 0 literally (no feedback) rather than auto-selecting a primitive polynomial. By contrast Synth(pn_poly=0) does auto-select an MLS polynomial — so the two PN entry points disagree on what poly = 0 means. Reference: native/src/wfm/wfm_* PN core + the pn object binding in native/src/wfm/wfm_ext_pn.c.

Workaround (today). Always pass an explicit primitive polynomial: PN(poly=w.mls_poly(length), …).

Fix options (follow-up PR). Either (a) make the binding default poly actually 96 (or mls_poly(length)), or (b) make the PN core treat poly = 0 as "auto-select MLS for length", matching Synth(pn_poly=0). Option (b) is the more consistent contract. Either fix should also align the .pyi docstring.

cli-output-dash — wfmgen --output - writes a file named -, not stdout

✅ Resolved in #196 (closes #192) — wfmgen --output - now writes to stdout. The xfail is removed; the check is now a positive regression test.

Symbol: wfmgen CLI (--output/-o) Severity: low (documented stdout idiom silently writes a stray file) xfail: TestCLI::test_output_dash_is_stdout

Expected. docs/guide/wfmgen.md:396 states "(or -) it prints to stdout".

Observed. wfmgen --type tone --count 256 --output - creates a file literally named - in the cwd (2048 bytes) and writes nothing to stdout.

$ wfmgen --type tone --count 256 --sample-type cf32 --output - >/dev/null
$ ls -l ./-          # a stray 2048-byte file appears

The omitted---output form does go to stdout correctly:

$ wfmgen --type tone --count 256 --sample-type cf32 > out.iq   # 2048 bytes, OK

Suspected root cause. native/src/app/wfmgen.c treats the - argument as an ordinary output path (fopen("-")) instead of special-casing it to stdout.

Workaround (today). Omit --output entirely to stream to stdout.

Fix options (follow-up PR). Either special-case --output - to stdout in wfmgen.c (matching the docs), or drop the - claim from the docs and rely on the omitted---output stdout default. The former matches common CLI convention.

zmqsink-stream-dtype-gap — doppler.stream can't decode ZmqSink cf32/ci16/ci8

✅ Resolved in #196 (closes #193) — doppler.stream now decodes all six dp_sample_type_t wire types (cf32/ci16/ci8 added). The xfail is removed and test_zmqsink_cf32_decodes_in_stream is now a positive round-trip test.

Symbols: doppler.wfm.ZmqSinkdoppler.stream.Subscriber/Pull Severity: high (the default ZmqSink sample type is undeliverable to the Python receiver — a C transmitter cannot talk to a Python subscriber for the common case, contra the "shared wire formats" architecture rule) xfail: TestZmqSinkAndClock::test_zmqsink_cf32_decodes_in_stream

Expected. ZmqSink and doppler.stream share one wire enum (dp_sample_type_t, native/inc/stream/stream.h:84):

CI32 = 0, CF64 = 1, CF128 = 2, CI8 = 3, CI16 = 4, CF32 = 5

The C sink emits the correct code for every type (wfm_sink.c:91-112, WT_CF32 → CF32 etc.), so a Subscriber should decode any of them.

Observed. The Python doppler.stream binding only implements three of the six members — it exposes CI32/CF64/CF128 and recv raises ValueError("Unknown sample_type: N") for the other three. Measured over a live ipc:// PUB→SUB round-trip:

ZmqSink sample_type wire code Subscriber.recv
cf32 (default) 5 ValueError: Unknown sample_type: 5
cf64 1 OK (complex128)
ci32 0 OK (int32 interleaved)
ci16 4 ValueError: Unknown sample_type: 4
ci8 3 ValueError: Unknown sample_type: 3

So the most common path — wfmgen --output zmq://… (cf32 by default) consumed by a doppler.stream subscriber — silently fails on the receive side.

Suspected root cause. The receive/decode table in the stream CPython binding (native/src/stream/* — the recv sample-type switch) and its exported module constants cover only CI32/CF64/CF128; CF32/CI16/CI8 were never added on the Python side even though the C enum and the C senders (dp_pub_send_cf32/_ci16/_ci8) support them.

Workaround (today). Publish from ZmqSink with sample_type="cf64" (or "ci32") when the consumer is doppler.stream; reserve cf32/ci16/ci8 for file containers (Writer/read_iq handle all five).

Fix options (follow-up PR). Teach the stream receive binding the full dp_sample_type_t enum (add the CF32/CI16/CI8 decode arms + export the constants) so every wire type round-trips. This is a stream-module fix, not a wfm one. Removing the strict xfail is the signal that it landed.


Behaviour notes (not bugs — documentation gaps)

compose-repeat-unbounded — Composer.compose() never returns on a repeat/continuous spec

Symbols: doppler.wfm.Composer(repeat=True | continuous=True) Covered by: TestComposerGraph::test_to_dict_and_json_roundtrip (asserts the flag round-trips through JSON, but composes the finite spec).

compose() materialises the entire stream into one array. A repeat=True (loop the sequence) or continuous=True (never stop) timeline has no bounded length, so compose() on such a spec loops forever / grows without bound. This is intendedrepeat/continuous exist for the streaming faces (stream(), execute(n), the CLI --continuous), which pull bounded blocks — but it is an easy trap and is undocumented on the compose() method.

>>> import doppler.wfm as w
>>> c = w.Composer([w.Segment("tone", freq=1e5, num_samples=128)], repeat=True)
>>> c.to_dict()["repeat"]      # the flag is set and round-trips through JSON
True
>>> # c.compose()             # DON'T: unbounded, never returns
>>> blocks = []                # DO: pull bounded blocks instead
>>> for i, b in enumerate(c.stream(block=128)):
...     blocks.append(b)
...     if i == 3: break
>>> sum(len(b) for b in blocks)
512

Action (Part C): document that compose() requires a finite (non-repeat, non-continuous) spec, and point repeating/streaming users to stream() / execute(n), in docs/design/wfmgen-composition.md and the Composer API docs.

csv-reader-count — Reader.num_samples is 0 for CSV captures

Symbols: doppler.wfm.Reader (CSV) Covered by: TestReaderWriter CSV round-trip (asserts read() works).

A Reader opened on a CSV capture reports num_samples == 0 even though read(n) returns the correct samples and they round-trip bit-faithfully. CSV has no header to give a cheap count, so the reader cannot pre-report the length without a full scan. This is a limitation, not a bug — but the num_samples property silently returning 0 (rather than, say, counting lines) is worth documenting. The raw/BLUE/SigMF readers report the true count.

noise-scaling — level/snr are composition-time gains, not Synth gains

Symbols: doppler.wfm.Synth(type="noise"), Composer Covered by: TestNoise::test_bare_synth_is_unit_power, test_bare_synth_ignores_snr, test_composer_applies_level_gain

A standalone Synth is the raw kernel. Synth(type="noise").steps(n) always emits unit complex power (σ² = 0.5 per quadrature) regardless of snr, snr_mode, or level:

>>> import numpy as np, doppler.wfm as w
>>> for lvl in (0.0, -20.0):
...     x = w.Synth(type="noise", level=lvl, seed=7).steps(100_000)
...     round(float(np.mean(np.abs(x)**2)), 3)
0.999
0.999

The per-segment level (dBFS) gain — and, for multi-source segments, the snr-driven noise-floor placement (native/src/wfm/wfm_resolve.c) — are applied by the Composer, which post-multiplies each source by 10**(level/20) (native/src/wfm/wfm_compose.c). So scaling only appears once samples flow through a Composer/Segment:

>>> c = w.Composer([w.Segment("noise", level=-20.0, num_samples=100_000, seed=7)])
>>> round(float(np.mean(np.abs(c.compose())**2)), 3)
0.01

This is intended architecture (the synth kernel stays scale-free; the composer owns amplitude), but it is easy to trip over and is currently undocumented. Action (Part C): document the raw-kernel-vs-composer amplitude split in docs/design/wfmgen-composition.md and the Synth/Composer API docs, with the worked numerical example.

pn-generate-aliasing — PN.generate() returns a reused zero-copy buffer

Symbol: doppler.wfm.PN.generate Covered by: tests .copy() every generate() result.

PN.generate(n) returns a zero-copy NumPy view over a single reused buffer; a second generate() call overwrites the first result in place (np.shares_memory(a, b) is True). This is documented in the method docstring ("copy the result before calling generate again"), so it is a note, not a bug — but it is a sharp edge worth surfacing in the gallery/PN docs.

version-skew-0.17.0 — HEAD carries unreleased public API under 0.17.0

Symbols: doppler.wfm.Synth(bits=…), doppler.wfm.Composer.to_sigmf Surfaced by: the Python 3.9 e2e container (deploy/validation/wfm_e2e.py) run against the published doppler-dsp==0.17.0 wheel.

The repo's working tree (version 0.17.0 in pyproject.toml) exposes public API that the published 0.17.0 wheel does not: the Synth(bits=…) constructor kwarg and Composer.to_sigmf. Running the e2e against the PyPI wheel fails those two paths (TypeError: unexpected keyword argument 'bits'; AttributeError: 'Composer' object has no attribute 'to_sigmf') — they were added after the 0.17.0 tag without a version bump, so installing the wheel and building from a 0.17.0 checkout give different public surfaces.

This is a release-hygiene note, not a code bug: the next release must bump the version so the new API ships under a number that advertises it. The e2e script feature-detects both (exercises them when present, degrades to the wfmgen CLI for SigMF otherwise), so it validates the current wheel and the next release unchanged. Action: bump version before the next publish.