Skip to content

AsyncDsssPool: emitters that come and go

What each receiver holds over time, receiver 1's symbols and symbol lock through a release and a new emitter, and each receiver's last 600 symbols

One dsss.AsyncDsssPool runs three receivers on one stream. Four emitters on one Gold code reach it at different times, code phases and carriers. A, B and C arrive one after another, B leaves, and D arrives after it.

The pool's controller runs one search over the whole carrier range while any receiver is idle. It leaves out the code phases the receivers hold, so the search never takes a signal already held, and seeds each idle receiver from its picks, strongest first, so no two receivers take one signal. When a receiver's emitter leaves, its release rule fires, the receiver returns to idle, and the search seeds it again.

What the run shows

  • One receiver per emitter. While A, B and C are on the air, each is held by exactly one receiver, and no two receivers hold one signal.
  • A departure frees a receiver. After B leaves, its receiver is released once both of its lock detectors have stayed down for lost_confirm_s, and it returns to idle for the controller's search to seed again.
  • The freed receiver takes the newcomer. D is taken by the receiver B freed, without disturbing the two that track A and C.

How it is built

Each emitter is wfm.Synth in continuous DSSS mode, placed on the stream at its own sample offset and carrier, with source.AWGN at the Es/N0 every emitter shares.

import numpy as np

from doppler.examples._extra import require
from doppler.source import AWGN
from doppler.wfm import Gold, Synth, wfm_awgn_amplitude

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 per push
CARRIER_UNCERTAINTY = 5000.0  # Hz, one-sided: the search range
ESN0_DB = 10.0  # Es/N0 of each emitter at the input
CN0_DBHZ = ESN0_DB + 10.0 * np.log10(SYM_RATE)  # C/N0, dB-Hz
LOST_CONFIRM_S = 0.1  # both lock detectors down this long before a release
N_RECEIVERS = 3
SEED_NOISE = 5

GOLD = np.asarray(Gold().generate(SF), np.uint8)

#: The emitters: name, carrier (Hz), code-phase offset (samples into its
#: first epoch, so each sits hundreds of chips from the others), and the
#: epochs it is on the air, [first, end).
EMITTERS = (
    ("A", 1500.0, 0, (90, 9600)),
    ("B", -2200.0, 600, (900, 4500)),
    ("C", 700.0, 1300, (1800, 9600)),
    ("D", -1000.0, 1800, (5700, 9600)),
)
N_EPOCHS = 9600  # 3.2 s


def make_stream(seed: int = SEED_NOISE) -> np.ndarray:
    """The capture: :data:`EMITTERS` on one Gold code, each from
    :class:`~doppler.wfm.Synth` in continuous DSSS mode with its own PRBS
    seed, placed on the stream at its own sample offset and carrier, plus
    :class:`~doppler.source.AWGN` at the Es/N0 every emitter shares.

    Returns
    -------
    x : NDArray[np.complex64]
        :data:`N_EPOCHS` code epochs.
    """
    n = N_EPOCHS * TE
    x = np.zeros(n, np.complex64)
    for k, (_, freq, offset, (a, b)) in enumerate(EMITTERS):
        s0, s1 = a * TE + offset, b * TE
        x[s0:s1] += np.asarray(
            Synth(
                type="dsss",
                fs=FS,
                seed=11 + k,  # each emitter its own data
                sps=SPC,
                data_code=GOLD.tobytes(),
                symbol_rate=SYM_RATE,
                freq=freq,
            ).steps(s1 - s0),
            np.complex64,
        )
    amp = float(
        wfm_awgn_amplitude(ESN0_DB - 10.0 * np.log10(FS / SYM_RATE), 1.0)
    )
    x += AWGN(seed=seed, amplitude=amp).generate(n).astype(np.complex64)
    return x


def emitter_phase(k: int, sample: int) -> float:
    """Emitter ``k``'s code phase, chips, at ``sample``: its code starts at
    chip 0 on its first sample."""
    _, _, offset, (a, _) = EMITTERS[k]
    return ((sample - (a * TE + offset)) / SPC) % SF

The pool is built like one receiver, plus n_receivers. lost_confirm_s arms each receiver's release rule. The pool is fed one code epoch per push, and the script reads every receiver's status() after each one.

def run(x: np.ndarray) -> dict:
    """Push the capture one code epoch at a time and record every
    receiver's status after each push.

    The pool is built like one :class:`~doppler.dsss.AsyncDsssReceiver`,
    plus ``n_receivers``. ``lost_confirm_s`` arms each receiver's release
    rule, which is what frees a receiver whose emitter left.

    Returns
    -------
    rec : dict
        ``state``, ``phase`` and ``locked``, each ``(epochs, receivers)``;
        ``symbols``, each receiver's last 600 symbols; and the pool's
        ``released_lost`` and ``released_duplicate`` counts.
    """
    pool = AsyncDsssPool(
        GOLD,
        n_receivers=N_RECEIVERS,
        chip_rate=CHIP_RATE,
        symbol_rate=SYM_RATE,
        sample_rate=FS,
        cn0_dbhz=CN0_DBHZ,
        doppler_uncertainty=CARRIER_UNCERTAINTY,
        lost_confirm_s=LOST_CONFIRM_S,
    )
    shape = (N_EPOCHS, N_RECEIVERS)
    state = np.zeros(shape, int)
    phase = np.zeros(shape)
    locked = np.zeros(shape, int)
    tail = [[] for _ in range(N_RECEIVERS)]
    rx1_t, rx1_s = [], []  # every symbol receiver 1 emits, and when
    for e in range(N_EPOCHS):
        pool.push(x[e * TE : (e + 1) * TE])
        for i in range(N_RECEIVERS):
            st = pool.status(i)
            state[e, i], phase[e, i], locked[e, i] = (
                st.state,
                st.chip_phase,
                st.locked,
            )
            sym = pool.symbols(i)
            tail[i] = (tail[i] + list(sym))[-600:]
            if i == 1 and len(sym):
                # Spread over the push that emitted them: a symbol's time
                # to within one code epoch.
                rx1_t.extend(
                    e * EPOCH_S + EPOCH_S * np.arange(len(sym)) / len(sym)
                )
                rx1_s.extend(sym)
    return {
        "state": state,
        "phase": phase,
        "locked": locked,
        "symbols": [np.asarray(t) for t in tail],
        "rx1_t": np.asarray(rx1_t),
        "rx1_s": np.asarray(rx1_s),
        "released_lost": pool.released_lost,
        "released_duplicate": pool.released_duplicate,
    }

The checks identify the emitter each receiver holds by its code phase, and assert the claims above from the pool's own reports.

def holder_of(rec: dict) -> np.ndarray:
    """Which emitter each receiver holds at each epoch's end: the index of
    the emitter within 1.5 chips of the receiver's code phase, or -1
    (searching). A receiver still holding an emitter that has left -- until
    its release fires -- reads that emitter."""
    out = np.full(rec["state"].shape, -1)
    for e in range(N_EPOCHS):
        end = (e + 1) * TE
        for i in range(N_RECEIVERS):
            if rec["state"][e, i] not in (1, 2):
                continue
            for k, (_, _, _, (a, _b)) in enumerate(EMITTERS):
                d = abs(rec["phase"][e, i] - emitter_phase(k, end))
                if e >= a and min(d, SF - d) <= 1.5:
                    out[e, i] = k
    return out


def check(rec: dict) -> dict:
    """The run's claims, asserted from the pool's own reports.

    Settling margins: an emitter is counted from 0.5 s after it arrives,
    and a departed one from ``lost_confirm_s`` plus 0.2 s after it leaves.
    """
    held = holder_of(rec)
    settle = int(0.5 / EPOCH_S)
    gone = int((LOST_CONFIRM_S + 0.2) / EPOCH_S)
    arrive_ms = {}
    for k, (name, _, _, (a, b)) in enumerate(EMITTERS):
        span = held[a + settle : b]
        n_holders = (span == k).sum(axis=1)
        assert (n_holders == 1).all(), (
            f"{name}: held by {set(n_holders)} receivers while on the air"
        )
        first = np.flatnonzero((held[a:b] == k).any(axis=1))[0]
        arrive_ms[name] = first * EPOCH_S * 1e3
        if b < N_EPOCHS:  # left: released within the confirm time
            assert not (held[b + gone :] == k).any(), (
                f"{name} still held after it left"
            )
            last = np.flatnonzero((held == k).any(axis=1))[-1]
            release_ms = (last + 1 - b) * EPOCH_S * 1e3
    # A departure freed a receiver, and the newcomer took it.
    assert rec["released_lost"] >= 1
    b_rx = int(np.flatnonzero((held == 1).any(axis=0))[0])
    d_rx = int(np.flatnonzero((held == 3).any(axis=0))[0])
    assert b_rx == d_rx, "D was taken by the receiver B freed"
    # Every receiver holding a signal at the end has symbol lock.
    for i in range(N_RECEIVERS):
        assert rec["locked"][-1, i] == 1, f"receiver {i} not symbol-locked"
    return {"arrive_ms": arrive_ms, "b_rx": b_rx, "release_ms": release_ms}

What you're seeing

  1. What each receiver holds (top): the emitters on the air, then one row per receiver, coloured by the emitter it holds. Grey is refining after a hand-off. The hatched span is receiver 1 still holding B after B left, until its release fires.
  2. Receiver 1 over time (middle): B's symbols, then the noise it emits after B leaves, while its symbol lock is down and before the release, then nothing while it is idle, then D's symbols. The symbol lock, status().locked, says when the symbols are a signal's.
  3. Each receiver's last 600 symbols (bottom): A, D and C, one BPSK constellation each.

Reproduce

pip install "doppler-dsp[examples]"   # matplotlib + scipy, once
python -m doppler.examples.async_dsss_pool_demo out.png

The design is in the async DSSS design, §5-§9.

Source: async_dsss_pool_demo.py.