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

Examples:

Create with defaults:

>>> from doppler.impairment import DopplerChannel
>>> obj = DopplerChannel(
...     fs=1000000.0,
...     carrier_hz=0.0,
...     doppler_ppm=0.0,
...     doppler_rate_ppm_s=0.0,
... )

fs property

fs: float

Fs.

carrier_hz property

carrier_hz: float

Carrier hz.

doppler_ppm property

doppler_ppm: float

Doppler ppm.

doppler_rate_ppm_s property

doppler_rate_ppm_s: float

Doppler rate ppm s.

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().

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

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                   # ~ 1000 / (1 + 20e-6): time dilation
(999,)
>>> 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.

Returns:

Type Description
int

Output.

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: 999 / 1e6
0.000999
>>> 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 exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.

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; the object keeps the parameters it was constructed with. Length is validated against state_bytes() before the blob is handed to the C core, and the core may reject it as well.

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.

...

GalleryAsync DSSS Receiver: the SPEC waveform through coupled Doppler, Doppler Channel — Clock Doppler as a Propagation Impairment