AsyncDsssReceiver: a signal's lifecycle¶
One dsss.AsyncDsssReceiver follows one continuous DSSS
emitter from the first code epoch it searches to the moment the emitter leaves,
and it acquires the emitter again when the emitter returns. The receiver is fed
one code epoch per push, with its telemetry attached. It reports its own state,
its code and symbol lock decisions, its lock metric, every symbol it emits and the
Doppler it handed over, and a lossless capture keeps every record. The checks and
the figure read only those records.
The setup is one emitter, CCSDS Gold-1023 at 3.069 Mcps, asynchronous BPSK at
2700 bps, 1500 Hz off nominal, at 10 dB Es/N0. The caller bounds the carrier at
±5 kHz, which is the receiver's one-sided doppler_uncertainty: the search covers
that half-range in either direction, and the caller sets it from the uncertainty
it actually has.
The timeline has six parts:
- Noise alone. The receiver searches and emits no symbols.
- Arrival. The emitter comes on. The receiver acquires it, refines the carrier estimate and starts tracking. Symbols begin only then.
- A short fade. The emitter drops for 10 ms, and the page checks that the lock is up before it.
- Back on the air. The emitter returns for 100 ms.
- Departure. The emitter leaves for 0.3 s. Both lock detectors fall, and
after the confirm interval the receiver goes to
lost. The caller resets it. - Return. A returning emitter comes on. The reset receiver searches, acquires it and tracks it.
What the receiver does at each stage¶
- Searching and refining emit nothing. The first symbols come once the receiver is tracking.
- Lock-up time and fade survival vary by draw. This run keeps tracking and the symbol lock through a 10 ms fade. Lock-up time varies between noise draws. How often a fade is ridden out is measured in the measurement record, under that record's own conditions. This setup is not that measurement, and this page does not restate its rate.
- The release rule is armed.
lost_confirm_sis the interval both lock detectors must stay down before the receiver gives up on the emitter. The setup sets it to 100 ms. Once it passes, the state islost, the loops stop, and samples are discarded untilreset(). - Reset returns the receiver to searching. A receiver that has locked cannot be reset onto the same signal. It searches again, and a returning emitter is acquired afresh.
The script prints one line a stage, with the intervals it measured: the arrival to tracking, the release after departure, and the re-acquisition after the return. Those are this run's numbers. The script asserts each claim on this page, so a change that breaks one fails the example rather than the prose.
How it works¶
The emitter is wfm.Synth in continuous DSSS mode,
with its own PRBS payload on the Gold code. Each off span is zeroed after
synthesis, so the noise floor is the same before, during and after every cut.
The noise is source.AWGN, with the per-component
sigma that wfm_awgn_amplitude gives for the Es/N0.
import numpy as np
from doppler.source import AWGN
from doppler.wfm import Gold, Synth, wfm_awgn_amplitude
# ── Setup: this scenario's numbers ─────────────────────────────────────────
# The caller knows the sample rate, the chip and symbol rates, and the carrier
# uncertainty it has. The receiver takes those, and the C/N0 it needs to size
# its detectors.
SF = 1023 # Gold code period, chips (CCSDS)
CHIP_RATE = 3.069e6 # chips per second
SPC = 2 # samples per chip
FS = CHIP_RATE * SPC # sample rate, 6.138 MSa/s
SYM_RATE = 2700.0 # BPSK symbols per second, asynchronous to the code
TE = SF * SPC # one code epoch, samples: one push
EPOCH_S = TE / FS # seconds of signal per push
CARRIER = 1500.0 # Hz, the emitter's carrier offset while on the air
CARRIER_UNCERTAINTY = 5000.0 # Hz, one-sided: the caller's own bound on it
ESN0_DB = 10.0 # Es/N0 of the emitter at the receiver input
CN0_DBHZ = ESN0_DB + 10.0 * np.log10(SYM_RATE) # C/N0, dB-Hz
PFA = 1e-3 # the detectors' false-alarm rate
LOST_CONFIRM_S = 0.1 # both lock detectors down this long before LOST
SEED_EMITTER = 0 # the synth's PN and payload draw
SEED_NOISE = 2 # the noise draw
GOLD = np.asarray(Gold().generate(SF), np.uint8)
#: The timeline, in code epochs: ``"on"`` carries the signal, ``"off"`` is
#: noise alone. Written once, read by the signal, the receiver run, the checks
#: and the figure.
TIMELINE = (
("off", 180), # 60 ms of noise: nothing to find yet
("on", 4500), # 1.5 s: acquire, refine, track, and let the lock come up
("off", 30), # 10 ms fade
("on", 300), # 100 ms back on the air
("off", 900), # 0.3 s: the emitter leaves, longer than the confirm time
("on", 4500), # a returning emitter, on the air for 1.5 s
)
def spans():
"""Each :data:`TIMELINE` segment as ``(kind, first_epoch, end_epoch)``."""
out, pos = [], 0
for kind, n in TIMELINE:
out.append((kind, pos, pos + n))
pos += n
return out
def make_timeline(seed: int = SEED_NOISE) -> np.ndarray:
"""The capture: an emitter switching on and off over :data:`TIMELINE`, with
noise running throughout.
The emitter is :class:`~doppler.wfm.Synth` in continuous DSSS mode. Its
payload is the synth's own PRBS, so the data is not a bit array this file
owns. Each ``"off"`` span is zeroed after synthesis, so the noise floor is
the same before, during and after every cut. The noise is
:class:`~doppler.source.AWGN`, with the per-component sigma that
:func:`~doppler.wfm.wfm_awgn_amplitude` gives for the Es/N0.
Parameters
----------
seed : int, default SEED_NOISE
The noise draw. Each draw is one realization of the same timeline; the
lock-up time and whether a dropout holds depend on it. The C twin
uses the same two seeds, so the default draw is the one it runs.
Returns
-------
x : NDArray[np.complex64]
The whole capture, ``sum(n for _, n in TIMELINE)`` code epochs long.
"""
n = sum(n for _, n in TIMELINE) * TE
sig = np.asarray(
Synth(
type="dsss",
fs=FS,
seed=SEED_EMITTER,
sps=SPC,
data_code=GOLD.tobytes(),
symbol_rate=SYM_RATE,
freq=CARRIER,
).steps(n),
np.complex64,
)
for kind, a, b in spans():
if kind == "off":
sig[a * TE : b * TE] = 0.0
amp = float(
wfm_awgn_amplitude(ESN0_DB - 10.0 * np.log10(FS / SYM_RATE), 1.0)
)
noise = AWGN(seed=seed, amplitude=amp).generate(n)
return (sig + noise).astype(np.complex64)
The receiver is built with the caller's rates, carrier uncertainty and release
interval. set_telemetry(tlm, "rx") registers every probe the receiver and its
loops publish, under rx., and a
MemoryCapture
keeps every record. set_now() stamps each push with its first sample. The caller
resets the receiver when it reports lost. Each probe reports at its own rate, so
per_push() turns the records into one row a push: a probe's last value at or
before the push, carried over pushes it wrote nothing in.
def receive(x: np.ndarray) -> dict:
"""Feed the capture one code epoch per push, with the receiver's
telemetry attached, and return everything it reported.
``doppler_uncertainty`` is one-sided: the search covers the carrier within
``CARRIER_UNCERTAINTY`` of nominal, in either direction. ``lost_confirm_s``
arms the release rule. Everything else is the receiver's default.
``set_telemetry(tlm, "rx")`` registers every probe the receiver and its
loops publish, under ``rx.``. The memory capture keeps every record, and
``set_now()`` stamps each push with its first sample, so every record
carries the sample index of the push that wrote it.
When the receiver reports ``lost``, the caller resets it. The receiver
decides that an emitter has gone; the caller decides what to do about it.
Returns
-------
series : dict
Each probe's records by name, as ``(sample_index, value)`` arrays.
"""
rx = AsyncDsssReceiver(
code=GOLD,
chip_rate=CHIP_RATE,
symbol_rate=SYM_RATE,
spc=SPC,
cn0_dbhz=CN0_DBHZ,
pfa=PFA,
doppler_uncertainty=CARRIER_UNCERTAINTY,
lost_confirm_s=LOST_CONFIRM_S,
)
tlm = Telemetry()
rx.set_telemetry(tlm, "rx")
emitted = 0
with MemoryCapture(tlm, TE, SampleClock(FS)) as cap:
for k in range(len(x) // TE):
tlm.set_now(k * TE)
emitted += len(rx.steps(x[k * TE : (k + 1) * TE]))
assert STATES[rx.status().state] == rx.lifecycle
if rx.lost:
rx.reset()
series = cap.read_dict(index=True)
# The lock metric reports once an emitted symbol: its records count them.
assert len(series["rx.sym.lock"][1]) == emitted
return series
def per_push(series: dict, name: str, n: int) -> np.ndarray:
"""``name``'s value as each of ``n`` pushes left it.
Each probe reports at its own rate: the state once a push, the symbol
lock once a symbol, the code lock once a discriminator update. So the
value a push left is the probe's last record at or before that push's
stamp, carried over pushes that wrote none, and 0 before its first.
"""
at, value = series[name]
last = np.searchsorted(at, np.arange(n) * TE, side="right") - 1
return np.where(last >= 0, value[np.maximum(last, 0)], 0.0)
def rows_of(series: dict) -> dict[str, np.ndarray]:
"""The receiver's records as one row per push: its state, its two lock
decisions, its lock metric, the symbols it emitted and the Doppler it
handed over."""
n = len(series["rx.state"][1]) # one state record a push
return {
"state": per_push(series, "rx.state", n).astype(int),
"code_locked": per_push(series, "rx.code.locked", n) > 0.5,
"locked": per_push(series, "rx.sym.locked", n) > 0.5,
"metric": per_push(series, "rx.sym.lock", n),
# The metric reports once an emitted symbol, so its records count them.
"symbols": np.bincount(
(series["rx.sym.lock"][0] // TE).astype(np.int64), minlength=n
),
"handover_hz": per_push(series, "rx.handover_hz", n),
}
The checks run on those rows. Where a bound is physical, the check asserts it: the receiver cannot release an emitter before its confirm interval has passed, and it cannot emit a symbol before it tracks. The other bounds are windows around this run, and they hold the order of the events.
def check(rows: dict[str, np.ndarray]) -> dict[str, float]:
"""Assert the lifecycle the receiver reported, and return the intervals
and the hand-over it measured.
The bounds are physical where a bound exists: the receiver cannot release
an emitter before its confirm interval has passed, and it cannot emit a
symbol before it tracks. The other bounds are generous windows around this
run, chosen to hold the order of the events, not to pin a number.
"""
ms = 1e3 * EPOCH_S
seg = spans()
on_at = seg[1][1] # the first arrival
fade = seg[2][1] # the short fade
depart = seg[4][1] # the departure
back = seg[5][1] # the returning emitter
state = rows["state"]
assert len(state) == seg[-1][2], "the receiver did not report every push"
# Noise alone: the receiver searches and emits nothing.
assert (state[:on_at] == SEARCHING).all(), "left searching before arrival"
# Arrival: tracking, and symbols only once tracking.
tracking = state == TRACKING
assert tracking.any(), "the receiver never reached tracking"
first = int(np.argmax(tracking))
acquire_ms = (first - on_at) * ms
assert 5.0 < acquire_ms < 250.0, f"arrival to tracking {acquire_ms:.0f} ms"
assert not rows["symbols"][:first].any(), "symbols before tracking"
# This run's fade and the stretch to the departure: tracking holds, the
# symbol lock stays up, and nothing is released before the emitter leaves.
assert rows["locked"][fade - 1], (
"this run: the lock was not up before the fade"
)
assert tracking[first:depart].all(), "left tracking before the departure"
lost = state == LOST
assert not lost[first:depart].any(), "LOST before the departure"
assert rows["locked"][fade:depart].all(), (
"this run: the lock fell before the departure"
)
# The departure: both lock detectors fall, and LOST comes only after the
# confirm interval, since both must be down for that long first.
assert lost[depart:back].any(), "the departure never released the emitter"
released = depart + int(np.argmax(lost[depart:back]))
assert (released - depart) * EPOCH_S >= LOST_CONFIRM_S, (
"released before the confirm interval"
)
# Both flags are down for the whole confirm interval before the release.
confirm = int(np.ceil(LOST_CONFIRM_S / EPOCH_S))
assert not rows["code_locked"][released - confirm : released].any(), (
"code lock was up during the confirm interval"
)
assert not rows["locked"][released - confirm : released].any(), (
"symbol lock was up during the confirm interval"
)
release_ms = (released - depart) * ms
# The reset: the caller's reset returns the receiver to searching.
assert (state[released + 1 : back] == SEARCHING).all(), (
"left searching during the gap after the reset"
)
# The return: the reset receiver acquires the new arrival afresh. It emits
# no symbols until it tracks, and it tracks again within the bound.
assert tracking[back:].any(), "the returning emitter was never acquired"
reacquired = back + int(np.argmax(tracking[back:]))
assert not rows["symbols"][released + 1 : reacquired].any(), (
"symbols before the returning emitter was tracked"
)
return_ms = (reacquired - back) * ms
assert 5.0 < return_ms < 250.0, (
f"re-acquired {return_ms:.0f} ms after return"
)
symbols_since = int(rows["symbols"][reacquired:].sum())
assert symbols_since > 0, "no symbols after re-acquiring"
# The hand-over: the refine hands the tracker a Doppler inside the
# caller's uncertainty, and the receiver reports it from then on.
handover_hz = float(rows["handover_hz"][first])
assert abs(handover_hz) <= CARRIER_UNCERTAINTY, (
f"handed over at {handover_hz:.0f} Hz, outside the search"
)
lock_up_ms = int(np.argmax(rows["locked"][on_at:])) * ms
return {
"noise_ms": on_at * ms,
"lock_up_ms": lock_up_ms,
"acquire_ms": acquire_ms,
"release_ms": release_ms,
"reacquire_ms": return_ms,
"symbols_since": symbols_since,
"handover_hz": handover_hz,
"fade_ms": (seg[2][2] - fade) * ms,
}
Reading it when it does not behave¶
Read the probes in this order:
rx.state: searching (0), refining (1), tracking (2) or lost (4).rx.code.locked: the code loop's lock detector.rx.sym.locked: the symbol lock.rx.sym.lock: the lock metric, the value the symbol lock thresholds.
Searching that never ends means no detection at this uncertainty. Refining that
never ends means the carrier estimate has not converged, and rx.handover_hz
says what the refine settled on when it did. Tracking with rx.sym.locked low
means the symbol lock has not come up yet, which is the lock-up time varying by
draw. The receiver's header lists every probe it registers
(dp_async_dsss_receiver_set_telemetry()).
The carrier probes are the loop's own view, not Hz: rx.car.freq (the loop's
frequency memory) and rx.car.nco (its whole command) are cycles a sample at
the input rate, so multiply by the sample rate, chip_rate * spc, for Hz. That
is how the figure's bottom panel draws the carrier. The live carrier in Hz is
status().doppler_hz.
To keep a run's record and look at it outside the script, open a file capture in place of the memory one. The file and its JSON sidecar are the whole record:
from doppler.dsss import AsyncDsssReceiver
from doppler.telemetry import Capture, Telemetry
from doppler.wfm import SampleClock
rx = AsyncDsssReceiver(
code=GOLD,
chip_rate=CHIP_RATE,
symbol_rate=SYM_RATE,
spc=SPC,
cn0_dbhz=CN0_DBHZ,
doppler_uncertainty=CARRIER_UNCERTAINTY,
)
tlm = Telemetry()
rx.set_telemetry(tlm, "rx")
x = make_timeline()[: 600 * TE] # the noise, the arrival and its lock-up
with Capture(tlm, TE, "rx.tlm", SampleClock(FS)) as cap:
for k in range(len(x) // TE):
tlm.set_now(k * TE)
rx.steps(x[k * TE : (k + 1) * TE])
assert cap.count > 0 and cap.dropped == 0
From a doppler checkout, scripts/plot_capture.py draws any capture, one panel
a probe on a shared time axis:
A name matches by suffix, so car.nco alone would draw the demodulator's
carrier loop, rx.demod.car.nco, beside the receiver's; the full names pick
one each.
In C the same record is dp_tlm_capture_open (tlm, TE, "rx.tlm", &clk) in place
of dp_tlm_capture_open_memory(); the
C twin
shows the attach, the stamping and the read-back.
What you're seeing¶
- State and symbol lock (top). The black line is the state: searching,
refining, tracking, or lost. The green fill is the symbol lock. The grey spans
mark the signal-off intervals. The lock stays up through the fade and falls once
the emitter has gone. The state moves to
lostonly after the confirm interval, and the reset takes it back to searching before it tracks the returning emitter. - The lock metric (middle). The metric is the SNR-weighted running mean of cos(2φ) over the symbols, the value the lock flag reads. The panel shows how far the metric dipped in each gap, which the binary flag does not.
- The carrier (bottom). The orange step is
rx.handover_hz. While the receiver refines it reads the search's coarse seed, which can sit outside this panel's range; from the hand-over on it reads the refined Doppler the tracker was built from. The purple trace is the carrier loop's command in Hz, which pulls the hand-over in to the emitter's carrier, the dotted line. The loop reports once per carrier update, and it makes none while it holds after the departure or while the receiver searches, so the trace breaks there.
Reproduce¶
The C twin is
async_dsss_receiver_lifecycle.c.
It runs the same draw, the same seeds into the same sources, and checks the same
physics, so it prints the same five lines as the script; a test runs both and
requires that. It also shows what the binding does for you: the caller-owned
buffers, the telemetry context, and the teardown.
The design, including the state machine and the release rule, is in the async DSSS receiver design.
Source: async_dsss_receiver_lifecycle.py.
