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 carryingRcchips/s andRssymbols/s comes out atRc(1+d)andRs(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 isfc·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:
Progress through the stream is readable, and offset_hz reflects the ramp:
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,
... )
elapsed_s
property
¶
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
¶
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
¶
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
¶
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 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
¶
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
¶
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, |
set_state
¶
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 |
required |
destroy
¶
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 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. |
...
|
Related pages¶
Gallery — Async DSSS Receiver: the SPEC waveform through coupled Doppler, Doppler Channel — Clock Doppler as a Propagation Impairment