Skip to content

Python Impairment API

The doppler.impairment module holds propagation impairments — things that transform a signal on its way from transmitter to receiver. It is deliberately distinct from doppler.source, whose AWGN/LO/NCO generate a signal rather than act on one.

DopplerChannel is the first member: clock Doppler, applied to any complex baseband stream.

Source: src/doppler/impairment/__init__.py

See the Doppler Channel gallery page for the measured carrier offset, code-slip accumulation, and ramp behaviour.


How it works

A Doppler shift is not a frequency offset. Relative motion rescales the entire received time base, so every clock in the signal moves together — carrier, chip rate, symbol rate, frame rate. Modelling only the carrier is the common shortcut, and it removes exactly the error a delay-lock loop exists to track.

DopplerChannel derives both halves from one parameter:

  • Time-base dilation — the stream is resampled at output/input ratio 1/(1+d). Because the resampling acts on the whole stream, a signal carrying Rc chips/s and Rs symbols/s comes out at Rc(1+d) and Rs(1+d) automatically; there is no per-clock adjustment to keep consistent.
  • Carrier offset — multiplication by exp(j·2π·fc·excess(t)), whose instantaneous frequency is fc·d(t).

Both read from the same dilation integral excess(t) = ∫d dt, so they cannot drift apart.

Doppler is specified in ppm

Parts per million of the nominal time base, which makes the parameter carrier-frequency agnostic: one number is simultaneously an offset in Hz and a rate error in chips/s. At a 2.5 GHz carrier and a 3.069 Mcps code, 20 ppm is both +50 kHz and +61.4 chips/s.

doppler_rate_ppm_s ramps d linearly for a pass-like geometry — 0.2 ppm/s is 500 Hz/s at 2.5 GHz.

carrier_hz is DSP input here, not metadata

Elsewhere in this codebase (notably wfmgen --fc) the carrier frequency is a SigMF annotation that never touches a sample. In DopplerChannel it is load-bearing: Doppler is dimensionless ppm, and carrier_hz is the only thing that converts it into Hz. Leaving it at 0 still dilates the clocks but leaves the carrier stationary — a physically inconsistent capture, and permitted only because it is occasionally useful for isolating a code loop under test.

The ramp is an integral

excess(t) = d0·t + ½·ḋ·t². Accumulating t·d(t) instead would double-count the ramp and put the instantaneous offset at fc·(d0 + 2·ḋ·t) — exactly twice the intended Doppler rate. That error passes every static-Doppler check, so both the C and Python suites assert against it specifically.


Usage

import numpy as np

from doppler.impairment import DopplerChannel

FS = 6.138e6  # 3.069 Mcps at spc=2

ch = DopplerChannel(
    fs=FS,
    carrier_hz=2.5e9,
    doppler_ppm=20.0,  # +50 kHz at this carrier
    doppler_rate_ppm_s=0.2,  # 500 Hz/s
)

x = np.ones(65536, dtype=np.complex64)
y = ch.execute(x)

Output length is approximately len(x)/(1+d) — the missing samples are the dilation:

assert len(y) < len(x)

Progress through the stream is readable, and offset_hz reflects the ramp:

assert ch.elapsed_s > 0.0
assert ch.offset_hz > 50_000.0  # d0 plus whatever the ramp has added

Streaming

execute carries state between calls, so feeding blocks matches one large call. Feed at most 65536 samples per call (see DOPPLER_CHANNEL_MAX_BLOCK in the C header for why the bound cannot depend on the input length):

ch.reset()
out = [ch.execute(x[i : i + 4096]) for i in range(0, len(x), 4096)]
y2 = np.concatenate(out)

Checkpoint / resume

Like every stateful object here, it resumes bit-for-bit from a blob:

blob = ch.get_state()
other = DopplerChannel(
    fs=FS, carrier_hz=2.5e9, doppler_ppm=20.0, doppler_rate_ppm_s=0.2
)
other.set_state(blob)

DopplerChannel

DopplerChannel component.

Parameters:

Name Type Description Default
fs float

fs constructor parameter.

1000000.0
carrier_hz float

carrier_hz constructor parameter.

0.0
doppler_ppm float

doppler_ppm constructor parameter.

0.0
doppler_rate_ppm_s float

doppler_rate_ppm_s constructor parameter.

0.0

Raises:

Type Description
ValueError

If construction fails. The exception message is DopplerChannel: invalid parameter (need fs > 0, a time scale 1 + doppler_ppm * 1e-6 > 0, and an initial rate 1 / scale the resampler accepts).

Examples:

Create with defaults:

>>> from doppler.impairment import DopplerChannel
>>> obj = DopplerChannel()

fs property

fs: float

receive sample rate, Hz (config)

carrier_hz property

carrier_hz: float

RF carrier fc, Hz — drives the offset (config; 0 = clocks only)

doppler_ppm property

doppler_ppm: float

d0, ppm of nominal (config)

doppler_rate_ppm_s property

doppler_rate_ppm_s: float

d-dot, ppm/s (config)

elapsed_s property

elapsed_s: float

Receive time in seconds consumed so far, the t every Doppler quantity is evaluated at. Advances by n/fs per execute(x) call and is zeroed by reset().

delay_samples property

delay_samples: float

The resampler's group delay in samples (10.5 for the built-in bank), constant and in addition to the dilation: output k at receive time t = k/fs carries the input at t + excess(t) - delay_samples/fs. A receiver started at the input's phase is this far from the peak.

offset_hz property

offset_hz: float

Instantaneous carrier offset fc * d(t) in Hz at the current elapsed_s -- the frequency a receiver would have to tune out right now. Read-only diagnostic; with a non-zero doppler_rate_ppm_s it ramps as the stream advances.

execute

execute(
    x: NDArray[complex64],
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

Apply clock Doppler to a block of complex baseband.

Resamples x by 1/(1+d(t)) and multiplies the result by the coherent carrier exp(j*2*pi*fc*excess(t)). State persists across calls, so feeding a stream in blocks gives the same samples as one large call (subject to DOPPLER_CHANNEL_MAX_BLOCK).

Output length is approximately x_len/(1+d) and varies by a sample from call to call as the fractional resampling accumulator crosses — that variation is the dilation itself, not a defect.

Parameters:

Name Type Description Default
x NDArray[complex64]

Input block.

required
out NDArray[complex64] | None

Output buffer.

None

Returns:

Type Description
NDArray[complex64]

Samples written to out.

Examples:

>>> import numpy as np
>>> from doppler.impairment import DopplerChannel
>>> ch = DopplerChannel(fs=1e6, carrier_hz=2.5e9, doppler_ppm=20.0)
>>> y = ch.execute(np.ones(1000, dtype=np.complex64))
>>> y.shape                   # 20 ppm is 0.02 samples over this block
(1000,)
>>> round(ch.offset_hz, 1)    # fc * d = 2.5e9 * 20e-6, in Hz
50000.0

execute_max_out

execute_max_out() -> int

Upper bound on the output of one execute() call.

Assumes an input of at most DOPPLER_CHANNEL_MAX_BLOCK samples — see that macro for why the bound cannot depend on the actual input length. The bound uses the smallest time-base scale over the next block, so with a ramp it changes from call to call; re-query it before each call.

Returns:

Type Description
int

DOPPLER_CHANNEL_MAX_BLOCK / min(1+d) + 2 samples.

execute_profile

execute_profile(
    x: NDArray[complex64],
    ppm: NDArray[float64],
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

Apply a per-sample Doppler PROFILE to a block of complex baseband.

The array form of dp_doppler_channel_execute(): instead of the create-time (doppler_ppm, doppler_rate_ppm_s) closed form -- a straight line, which a real pass is not -- the Doppler is supplied as one value per INPUT sample. The length contract is the one dp_resamp_execute_ctrl() underneath already has (ctrl parallel to in), so a profile is handed to the resampler rather than reduced to fit it.

The profile is ABSOLUTE. ppm[i] is the total instantaneous Doppler at input sample i; the create-time scalars do not add to it. They cancel exactly rather than by convention: the resampler's rate is base + ctrl, and this fills ctrl = ratio(ppm[i]) - base with the same base it was built with. Creating with zeros and supplying a profile is the ordinary use.

The carrier is read off the resampler, not integrated beside it. The excess delay at output k is (p_k - k + 1)/fs, where p_k is the input position the resampler's own accumulator reports for that output (dp_resamp_execute_ctrl_pos()). So the carrier exp(j*2*pi*fc*excess) is derived from the dilation the resampler actually performed:

  • It cannot disagree with the dilation, and there is no second accumulator to drift from the first.
  • It cannot depend on how the stream was chunked: nothing here maps a profile index to an output index, which is what made the first attempt at this (reverted) chunk-dependent. Output k has the carrier of the position it was interpolated at, whatever call it fell in.
  • It has no cancellation: the integer part of p_k - k is exact, and only a fraction below one input sample is floating point.

Mixing the two calls on one stream is permitted and coherent -- both advance the same clocks -- and dp_doppler_channel_get_offset_hz() reports whichever drove the most recent call: the profile's last value after this, the closed form again after the next dp_doppler_channel_execute(). The profile is in the serialized state (layout version 2) only as that last value; the carrier needs none.

A sign change mid-record is the point: no (doppler_ppm, doppler_rate_ppm_s) pair produces it.

Parameters:

Name Type Description Default
x NDArray[complex64]

Input CF32 samples, x_len of them.

required
ppm NDArray[float64]

Doppler in ppm, parallel to x.

required
out NDArray[complex64] | None

Output buffer.

None

Returns:

Type Description
NDArray[complex64]

Samples written (>= 0); in Python, the array's length. A refused call writes nothing, leaves the clocks alone and returns DP_ERR_INVALID (negative), so a refusal is never mistaken for an empty answer: 0 is a valid result (a one-sample call returns it about half the time at +1e6 ppm). Refused: a NULL pointer, a ppm_len that differs from x_len, or a profile sample that is non-finite or below -5e5 ppm (a scale under 1/2: past the 2x expansion the output is sized for, and at -1e6 ppm time stops, which create() already refuses for the scalar). Also refused: a max_out too large to size the position scratch (max_out * sizeof (double) overflows a size_t, i.e. above SIZE_MAX / 8), which no real buffer can be. All checked BEFORE any output is produced, so a bad call writes nothing rather than a valid prefix: elapsed_s is unchanged and dp_doppler_channel_consumed() reads 0.

Raises:

Type Description
ValueError

If the C call returns a negative value. The exception message is execute_profile refused: x and ppm must be the same length and every ppm sample finite and above -5e5, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.impairment import DopplerChannel
>>> ch = DopplerChannel(fs=1e6, carrier_hz=2.5e9)
>>> n = 1000
>>> ppm = np.where(np.arange(n) < n // 2, 20.0, -20.0)
>>> y = ch.execute_profile(np.ones(n, dtype=np.complex64), ppm)
>>> y.shape          # closing then opening: the record STRETCHES overall
(1001,)
>>> round(ch.offset_hz, 1)   # fc * d at the last profile sample
-50000.0

execute_profile_max_out

execute_profile_max_out(x_len: int) -> int

The BINDING's output bound for execute_profile() (jm pass_capacity).

The generated binding knows the INPUT LENGTH n -- it passes it -- but has not looked at the profile array, so it knows how many samples go in and not how far they dilate. With the profile unseen there is no exact answer: the bound scales n by a floor on the scale and the kernel clamps to the caller's real capacity, as Resampler_execute_ctrl_max_out does for its equally arbitrary ctrl. The floor allows a 2x expansion, i.e. a Doppler of -500000 ppm -- half the speed of light closing, six orders of magnitude past any geometry this object models.

Parameters:

Name Type Description Default
x_len int

Input.

required

Returns:

Type Description
int

The capacity the binding allocates.

reset

reset() -> None

Reset DopplerChannel to its post-create state.

Zeroes both sample clocks (so elapsed_s and the carrier phase restart at zero) and clears the resampler's delay line and fractional accumulator. The configured fs/carrier_hz/doppler_ppm/doppler_rate_ppm_s are kept.

Examples:

>>> import numpy as np
>>> from doppler.impairment import DopplerChannel
>>> ch = DopplerChannel(fs=1e6, carrier_hz=2.5e9, doppler_ppm=20.0)
>>> _ = ch.execute(np.ones(1000, dtype=np.complex64))
>>> round(ch.elapsed_s, 6)    # receive time consumed: 1000 / 1e6
0.001
>>> ch.reset()                # both sample clocks back to zero
>>> ch.elapsed_s
0.0

state_bytes

state_bytes() -> int

Size in bytes of this object's serialized state.

The exact length get_state returns and set_state requires. It depends on how the object was constructed (state arrays are sized at construction), so read it from the instance rather than assuming a constant.

Raises RuntimeError if the DopplerChannel has already been destroyed.

Returns:

Type Description
int

Byte length of one serialized state blob.

get_state

get_state() -> bytes

Serialize this object's mutable state to bytes.

Captures the state that evolves as the object runs, including any value a setter changed after construction, so a blob taken now and restored later resumes from this point. Construction parameters are not restored: restore into an object built the same way. A core may check some of them and refuse a blob built differently.

The blob is opaque and always state_bytes() long. Its layout is an implementation detail of the C core and is not a stable format across builds.

Raises RuntimeError if the DopplerChannel has already been destroyed.

Returns:

Type Description
bytes

Opaque snapshot, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

Restore mutable state from a get_state() blob.

Overwrites the live state in place, including any value a setter changed after construction. Construction parameters are not restored: restore into an object built the same way. Length is validated against state_bytes() before the blob is handed to the C core, which may reject it too: a core may check some construction parameters and refuse a blob built differently.

Raises TypeError if blob is not bytes, ValueError if its length differs from state_bytes() or the core rejects it, and RuntimeError if the DopplerChannel has already been destroyed.

Parameters:

Name Type Description Default
blob bytes

A get_state() blob from this type, exactly state_bytes() long.

required

destroy

destroy() -> None

Release the underlying C resources immediately.

Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.

Idempotent: calling it again on an already-released object does nothing. Every other method raises RuntimeError once it has run.

__enter__

__enter__() -> DopplerChannel

Enter a context manager, returning this object.

Lets a DopplerChannel be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
DopplerChannel

This same object, not a copy.

__exit__

__exit__(
    exc_type: object | None = ...,
    exc: object | None = ...,
    tb: object | None = ...,
) -> None

Exit a context manager, releasing the DopplerChannel.

Equivalent to calling destroy(). Returns None, so an exception raised inside the with body propagates normally; this never suppresses one.

Parameters:

Name Type Description Default
exc_type object | None

Exception class, or None. Ignored.

...
exc object | None

Exception instance, or None. Ignored.

...
tb object | None

Traceback object, or None. Ignored.

...

Gallery — Streaming Async Despreader, AsyncDsssPool: ten emitters through six receivers, AsyncDsssReceiver: the specification link, Doppler Channel — a Doppler Profile Driven by a Cosine, Doppler Channel — Clock Doppler as a Propagation Impairment