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
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If construction fails. The exception message is |
Examples:
Create with defaults:
carrier_hz
property
¶
RF carrier fc, Hz — drives the offset (config; 0 = clocks only)
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().
delay_samples
property
¶
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
¶
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 |
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
¶
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
|
|
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
khas the carrier of the position it was interpolated at, whatever call it fell in. - It has no cancellation: the integer part of
p_k - kis 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 ( |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a negative value. The exception message is
|
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
¶
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 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
¶
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 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, |
set_state
¶
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 |
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 — 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