File doppler_channel_core.h¶
FileList > doppler > doppler_channel > doppler_channel_core.h
Go to the source code of this file
Clock Doppler as a propagation impairment: dilate the time base and shift the carrier, coherently, from one physical parameter. More...
#include <stdint.h>#include "doppler/clib_common.h"#include "doppler/dp_state.h"#include "doppler/jm_perf.h"#include "doppler/resamp/resamp_core.h"
Classes¶
| Type | Name |
|---|---|
| struct | dp_doppler_channel_state_t DopplerChannel state. |
Public Functions¶
| Type | Name |
|---|---|
| dp_doppler_channel_state_t * | dp_doppler_channel_create (double fs, double carrier_hz, double doppler_ppm, double doppler_rate_ppm_s) Create a doppler_channel instance. |
| void | dp_doppler_channel_destroy (dp_doppler_channel_state_t * state) Destroy a doppler_channel instance and release all memory. |
| size_t | dp_doppler_channel_execute (dp_doppler_channel_state_t * state, const float _Complex * x, size_t x_len, float _Complex * out, size_t max_out) Apply clock Doppler to a block of complex baseband. |
| size_t | dp_doppler_channel_execute_max_out (dp_doppler_channel_state_t * state) Upper bound on the output of one execute() call. |
| int64_t | dp_doppler_channel_execute_profile (dp_doppler_channel_state_t * state, const float _Complex * x, size_t x_len, const double * ppm, size_t ppm_len, float _Complex * out, size_t max_out) Apply a per-sample Doppler PROFILE to a block of complex baseband. |
| size_t | dp_doppler_channel_execute_profile_max_out (dp_doppler_channel_state_t * state, size_t n) The BINDING's output bound for execute_profile() (jm pass_capacity). |
| double | dp_doppler_channel_get_delay_samples (const dp_doppler_channel_state_t * state) The resampler's group delay, in samples (10.5 for the built-in bank). |
| double | dp_doppler_channel_get_elapsed_s (const dp_doppler_channel_state_t * state) Receive time in seconds produced so far ( n_out/fs ). |
| double | dp_doppler_channel_get_offset_hz (const dp_doppler_channel_state_t * state) Instantaneous carrier offset fc*d(t) in Hz atelapsed_s . |
| void | dp_doppler_channel_get_state (const dp_doppler_channel_state_t * state, void * blob) Serialize the running state: both sample clocks, the profile's last d and whether a profile has driven the stream, then the resampler's. |
| void | dp_doppler_channel_reset (dp_doppler_channel_state_t * state) Reset DopplerChannel to its post-create state. |
| int | dp_doppler_channel_set_state (dp_doppler_channel_state_t * state, const void * blob) Restore a blob written by dp_doppler_channel_get_state() . |
| size_t | dp_doppler_channel_state_bytes (const dp_doppler_channel_state_t * state) Bytes dp_doppler_channel_get_state() writes (envelope + payload). |
Public Static Functions¶
| Type | Name |
|---|---|
| double | doppler_channel_excess (const dp_doppler_channel_state_t * s, double t) Excess delay (seconds) accumulated by receive time t: __tau(t)-t . |
| double | doppler_channel_phase (const dp_doppler_channel_state_t * s, double t) Carrier phase in CYCLES at receive time t: __fc * excess(t) . |
| double | doppler_channel_scale (const dp_doppler_channel_state_t * s, double t) Instantaneous time-base scale 1 + d(t) at receive timet . |
Macros¶
| Type | Name |
|---|---|
| define | DOPPLER_CHANNEL_MAX_BLOCK 65536uLargest input block one dp_doppler_channel_execute() call accepts. |
| define | DOPPLER_CHANNEL_STATE_MAGIC [**DP\_FOURCC**](dp__state_8h.md#define-dp_fourcc)('D', 'P', 'C', 'H')State-blob magic ('DPCH') and layout version. |
| define | DOPPLER_CHANNEL_STATE_VERSION 2u |
Detailed Description¶
A real Doppler shift is not a frequency offset. Relative motion rescales the whole received time base, so every clock in the signal changes together — carrier, chip rate, symbol rate, frame rate. Modelling only the carrier is the classic unphysical shortcut, and it silently hides the code-rate error that a receiver's delay-lock loop exists to track.
This object takes any complex baseband stream and applies both halves of the effect from a single parameter, so they cannot disagree:
- Time-base dilation — the input is resampled at output/input ratio
1/(1+d), which makes a stream carryingRcchips/s andRssymbols/s come out atRc(1+d)andRs(1+d). One resampling on the whole stream, rather than a per-clock adjustment, is what keeps the clocks consistent. - Carrier offset — multiplication by
exp(j2*pi*fc*excess(t)), whose instantaneous frequency isfc*d(t).
Doppler is specified in ppm of the nominal time base, which makes it carrier-frequency agnostic: 20 ppm is +50 kHz at 2.5 GHz and +61.4 chip/s at 3.069 Mcps at the same time, and no caller converts between the two by hand. doppler_rate_ppm_s ramps it linearly for a pass-like geometry (0.2 ppm/s is 500 Hz/s at 2.5 GHz).
**carrier_hz is load-bearing, not metadata.** It is the only thing that converts a dimensionless ppm into a carrier offset in Hz. Leave it 0 and the clocks still dilate correctly but the carrier never moves — a physically inconsistent capture whose code rate runs fast while its carrier sits exactly on frequency. That combination is occasionally useful for isolating a code loop under test, so it is permitted rather than rejected, but it is not what a real channel does.
The dilation is dp_resamp_execute_ctrl (see resamp_core.h), whose per-sample rate deviation tracks the ramp exactly instead of approximating it with a piecewise-constant ratio re-set once per block. No resampling math is implemented here.
The output is delayed by the resampler's group delay dp_doppler_channel_get_delay_samples(), 10.5 samples for the built-in bank on top of the dilation. Output sample k, at receive time t = k/fs, carries the input at time t + excess(t) - delay/fs. A receiver started at the INPUT's phase is that far from the peak: at two samples per chip, five chips, outside a DLL's pull-in and onto a Gold code's sidelobe (doppler-dsp/doppler#1189). Subtract it, or start the loop there.
Lifecycle: create -> [execute / reset]* -> destroy
Example — a 2.5 GHz carrier seen at +20 ppm, ramping at 0.2 ppm/s:
dp_doppler_channel_state_t *ch =
dp_doppler_channel_create (6.138e6, 2.5e9, 20.0, 0.2);
size_t cap = dp_doppler_channel_execute_max_out (ch);
float _Complex *out = malloc (cap * sizeof *out);
size_t n = dp_doppler_channel_execute (ch, in, 65536, out, cap);
// n ~= 65536/(1+20e-6); dp_doppler_channel_get_offset_hz (ch) ~= 50000.0
free (out);
dp_doppler_channel_destroy (ch);
Public Functions Documentation¶
function dp_doppler_channel_create¶
Create a doppler_channel instance.
dp_doppler_channel_state_t * dp_doppler_channel_create (
double fs,
double carrier_hz,
double doppler_ppm,
double doppler_rate_ppm_s
)
Parameters:
fsReceive sample rate in Hz (> 0).carrier_hzRF carrier in Hz (>= 0). Load-bearing: converts ppm into a carrier offset. 0 dilates the clocks but never moves the carrier (default: 0.0).doppler_ppmDoppler d0 in ppm of the nominal time base; positive is a closing range — clocks run fast, carrier shifts up (default: 0.0).doppler_rate_ppm_sLinear ramp of d in ppm per second (default: 0.0).
Returns:
Heap-allocated state, or NULL on allocation failure or fs <= 0.
Note:
Caller must call dp_doppler_channel_destroy() when done.
function dp_doppler_channel_destroy¶
Destroy a doppler_channel instance and release all memory.
Parameters:
stateMay be NULL.
function dp_doppler_channel_execute¶
Apply clock Doppler to a block of complex baseband.
size_t dp_doppler_channel_execute (
dp_doppler_channel_state_t * state,
const float _Complex * x,
size_t x_len,
float _Complex * out,
size_t max_out
)
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:
stateMust be non-NULL.xInput block.x_lenInput length in samples.outOutput buffer.max_outCapacity ofout; production stops there.
Returns:
Samples written to out.
>>> 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
function dp_doppler_channel_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.
function dp_doppler_channel_execute_profile¶
Apply a per-sample Doppler PROFILE to a block of complex baseband.
int64_t dp_doppler_channel_execute_profile (
dp_doppler_channel_state_t * state,
const float _Complex * x,
size_t x_len,
const double * ppm,
size_t ppm_len,
float _Complex * out,
size_t max_out
)
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.
>>> 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
A sign change mid-record is the point: no (doppler_ppm, doppler_rate_ppm_s) pair produces it.
Parameters:
statechannel state.xInput CF32 samples,x_lenof them.x_lenInput sample count.ppmDoppler in ppm, parallel tox.ppm_lenProfile length; must EQUALx_len.outOutput buffer.max_outCapacity ofoutin samples.
Returns:
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). All checked over the whole profile BEFORE any output is produced, so a bad call writes nothing rather than a valid prefix.
function dp_doppler_channel_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:
statechannel state (unused; the signature is jm's).nInput sample count the binding is about to pass.
Returns:
The capacity the binding allocates.
function dp_doppler_channel_get_delay_samples¶
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/fs (doppler_channel_excess()). Input and receive samples differ by the ppm of Doppler, so the unit is either to that precision. dp_resamp_get_delay() for the derivation.
function dp_doppler_channel_get_elapsed_s¶
Receive time in seconds produced so far ( n_out/fs ).
function dp_doppler_channel_get_offset_hz¶
Instantaneous carrier offset fc*d(t) in Hz atelapsed_s .
function dp_doppler_channel_get_state¶
Serialize the running state: both sample clocks, the profile's last d and whether a profile has driven the stream, then the resampler's.
function dp_doppler_channel_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.
Parameters:
stateMust be non-NULL.>>> 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
function dp_doppler_channel_set_state¶
Restore a blob written by dp_doppler_channel_get_state() .
A refused blob leaves the channel unchanged: the channel's own fields are decoded first and written only once the resampler has accepted its part (doppler#2104).
Returns:
DP_OK, or DP_ERR_INVALID if the envelope or a child blob is rejected.
function dp_doppler_channel_state_bytes¶
Bytes dp_doppler_channel_get_state() writes (envelope + payload).
Public Static Functions Documentation¶
function doppler_channel_excess¶
Excess delay (seconds) accumulated by receive time t: __tau(t)-t .
The one place the dilation integral is evaluated; the scale and the carrier phase are both derived from it, so the clocks cannot drift apart — there is only ever one number to drift.
tau(t) = integral_0^t (1 + d(u)) du, d(u) = (d0 + d_dot*u) * 1e-6 tau(t) - t = d0*t + 0.5*d_dot*t^2
Note this is the integral, not t*d(t): the latter double-counts the ramp and puts the instantaneous offset at fc*(d0 + 2*d_dot*t), exactly twice the intended Doppler rate.
Parameters:
schannel state.treceive time in seconds (>= 0).
Returns:
Excess delay in seconds (negative for an opening-range geometry).
function doppler_channel_phase¶
Carrier phase in CYCLES at receive time t: __fc * excess(t) .
Its derivative is fc * d(t), the instantaneous offset — so the carrier is driven by the same dilation the clocks are, not by a separately-specified frequency that could be set inconsistently with them.
Evaluated closed-form from t rather than accumulated per sample: an incremental accumulator would drift, and the closed form keeps a long capture phase-exact (a 1000 s run at 20 ppm on a 2.5 GHz carrier is ~5e7 cycles, ~1e-8 cycles of representation error in double).
Parameters:
schannel state.treceive time in seconds (>= 0).
Returns:
Phase in cycles; multiply by 2*pi for radians.
function doppler_channel_scale¶
Instantaneous time-base scale 1 + d(t) at receive timet .
The reciprocal of the resampler's output/input ratio: a stream resampled at 1/(1+d) comes out with every one of its clocks running (1+d) times faster.
Parameters:
schannel state.treceive time in seconds (>= 0).
Returns:
1 + d(t), a number very close to 1.
Macro Definition Documentation¶
define DOPPLER_CHANNEL_MAX_BLOCK¶
Largest input block one dp_doppler_channel_execute() call accepts.
dp_doppler_channel_execute_max_out() reports a bound for the output buffer, and the generated Python binding sizes its buffer from that alone — it never sees the input length. So the bound has to assume a worst-case input, and this is that assumption (the same convention, and the same value, as dp_RateConverter_execute_max_out). Longer inputs are processed up to the caller's max_out and the remainder is not consumed; feed large streams in blocks of at most this many samples.
define DOPPLER_CHANNEL_STATE_MAGIC¶
State-blob magic ('DPCH') and layout version.
define DOPPLER_CHANNEL_STATE_VERSION¶
The documentation for this class was generated from the following file native/inc/doppler/doppler_channel/doppler_channel_core.h