Tracking a Population of DSSS Emitters with AsyncDsssPool¶
AsyncDsssPool holds the whole multi-emitter lifecycle behind one
push(): one searcher over the Doppler uncertainty, a pool of cell
receivers created idle, the assigned table that keeps a re-detection from
becoming a second receiver, and the event log that records every
transition at the sample it happened. Feed it the stream one block at a
time; read each slot's status and symbols after every push. This page is
how to use it. What it is and why it is shaped this way is
the design page; the numbers behind
every claim are the measurement record;
the runnable, tested walk-through of one lifecycle is
the gallery page.
When it is the right object¶
The use case: several emitters on the air at once on one spreading
code in one band, told apart by Doppler and code phase alone, each
transmitting continuously with a code-only window every frame,
arriving and leaving at their own times, for hours. One Acquisition
finds the strongest peak per dwell and one AsyncDsssReceiver tracks one
signal; the pool is what turns those into a population: every peak is
listed, every unassigned one seeds a free receiver, every receiver is
released when its own rule says its emitter is gone, and the searcher
never stops.
If there is one emitter, use AsyncDsssReceiver
directly (its searching flavor has the search built in). If the signals
are bursts, the burst chain is
DsssBurstReceiver.
The words¶
Nine words carry the object, in the order the stream produces them.
- cell — one test statistic value, at one code phase and one frequency, on the surface the searcher dumps for one coherent block (its dwell).
- hit — a cell over the threshold, listed as one detection record: a Doppler bin, a code phase, the statistic and a C/N0 estimate.
- seed — the three numbers a hit hands one receiver: chip phase, Doppler estimate, C/N0 estimate.
- slot — one of the pool's
n_slotsreceivers together with the table row that holds it, addressed by index instatus()andsymbols(). - row — one entry of the assigned table, one per slot: a slot's seed, and the coordinates the pool currently has for its emitter.
- zone — the code phase within one chip of a live row's, circular. A hit inside it is that row's emitter's own.
- release — the pool clearing a row and resetting its receiver to idle.
- stint — one continuous on-air period of one emitter.
- soak — one run of the whole population, long enough to contain arrivals, departures and returns.
Two of those words are used elsewhere in a second sense. On this page they mean what is written above and nothing else:
- row is the assigned table's row. The searcher's surface has rows
too — its Doppler axis, one row per resolvable frequency,
doppler_res_hzapart — and inside the block searcher each tile's slow-time rows fold onto that axis. This page calls that axis the Doppler bin and keeps row for the table. - cell is one value on the searcher's surface.
CellAsyncDsssReceiveris named after that cell — it is the receiver a searcher's cell drives, on the searcher's timing, instead of closing a code loop of its own. It has nothing to do with a radio cell.
The objects inside, and what each decides¶
| object | in the pool | decides |
|---|---|---|
Acquisition (continuous) |
one, over doppler_uncertainty, in coherent blocks of D epochs inside the code-only window, its tiles fanned across threads |
every hit above the gate, per dwell, up to max_peaks |
CellAsyncDsssReceiver × n_slots |
created idle; a seed starts the pull-in → track on the searcher's timing; every one is fed every block | its own lock flags, and lost — both flags down for lost_confirm_s |
| the assigned table | one row per slot: the seed, and the receiver's live Doppler and chip phase while its loops are locked | a hit inside a live row's zone is that emitter's own and seeds nothing |
EventLog (attached) |
borrowed by set_event_log() |
nothing — it records seeded, tracking, degrade, lost, released, dropped with the slot's coordinates |
The pool itself decides two things: which free slot a surviving hit seeds
(or dropped when there is none), and the on-time release — a slot
held past max_emitter_on_time_secs is released and its emitter is a new
detection at its next window.
Every slot is a CellAsyncDsssReceiver: its code loop held from the seed
and corrected once every D code periods on the searcher's own timing, at
gain (default 1/8) after pullin_intervals (default 4) at gain 1; the
seed's carrier residual is estimated on the receiver's own despread stream
and folded once. The constructor refuses a searcher a cell receiver cannot
take. Two bounds: a block depth above 1 (code_only_epochs > 1, with a
window in the waveform), and a Doppler bin no wider than four times the
carrier loop's pull-in bound — 97.8 Hz at the 5 Mcps / Gold-1023
geometry, so D ≥ 13 there — so that a seed half a bin off lands inside
the loop's pull-in. The operating point's D = 154 gives 31.7 Hz bins.
One slot's lifecycle¶
idle ──seed()──▶ refining ──pulled in──▶ tracking ──both flags down
▲ │ for lost_confirm_s
│ ▼
└───────── released (reset to idle) ◀──── lost
seeded— a listed hit outside every live row's zone, into a free slot. The seed carries the searcher's Doppler (to one bin), chip phase (to half a chip) and C/N0 estimate.refining— the pull-in. The receiver'sDllis held on the searcher's block timing from the first sample, and the seed's carrier residual is estimated on the receiver's own despread stream and folded once. This one is the receiver's own state, read offstatus(); the pool logs no transition for it.tracking— the pull-in has folded its estimate and a lock flag is up; from herestatus()reports the loops' own Doppler and chip phase, andsymbols()returns what the receiver decided on the last push.degrade— one flag down. Nothing is acted on; the loops keep running.lost— both flags down without a break forlost_confirm_s. While that clock runs the loops hold what they settled on rather than run on noise, so a departed emitter's receiver stands where the emitter left it and does not sweep onto a neighbour's code.released— the pool clears the row and resets the receiver to idle, either onlostor on the on-time cap. An emitter released while still on the air is re-acquired at its next code-only window into whichever slot is free: the one re-assignment the lifecycle permits.
The searcher's false alarms are part of this lifecycle by design: at
pfa = 1e-3 a noise hit seeds a free slot every few hundred milliseconds,
pulls in to nothing, reports tracking with both flags down and is released
one interval later. That is why an expectation about the pool is never a
count of assigned slots — a noise seed occupies one exactly as an emitter
does — and why n_slots carries headroom over the population.
Two rules meet here and are easy to merge into one. The pool occupies a slot by the code phase it was handed, and nothing else: that is why the zone it projects covers that code phase at any Doppler, since a tracked emitter's own data blocks put smeared copies of it at its own phase rows away (see the design page, §8.2). An observer identifies whose slot it is by both coordinates — the seed's Doppler within the searcher's row and its chip phase within a chip — because it is matching a slot against an emitter it already knows. The first is the mechanism. The second is how a test reads it, and it needs a truth the pool does not have.
Configuring it¶
Every number is a constructor parameter, and the searcher's and the receivers'
own parameters pass through untouched. The defaults are generic, not the
design's operating point: the 5 Mcps / Gold-1023 point this page quotes passes
its own chip_rate, doppler_uncertainty and doppler_rate=500 (the class
example does), and D = 154 comes from that rate bound — with the default
doppler_rate=0, code_only_epochs=813 gives D = 407.
| parameter | meaning | default |
|---|---|---|
code |
the spreading code, 0/1 chips; every emitter is on it | required |
chip_rate, symbol_rate, spc, m |
the waveform: chips per second, data symbols per second, samples per chip, the PSK order of the receivers | 1e6, 1000, 2, 2 |
cn0_dbhz, pfa, pd |
the sensitivity the searcher and the receivers are sized for, and the searcher's per-dwell false-alarm and detection targets | 55, 1e-3, 0.9 |
doppler_uncertainty |
the searcher's span, Hz one-sided; it tiles the span in windows one epoch rate wide | 100 |
code_only_epochs |
the whole code epochs the waveform's code-only window holds at any chip phase; sizes the coherent depth D, which must exceed 1 — and the Doppler-bin bound above puts its floor at 13 at the 5 Mcps / Gold-1023 geometry |
813 |
doppler_rate |
the Doppler rate the depth is bounded against, Hz/s (0 = no bound) | 0 |
max_peaks |
the hit list's capacity per dwell: the population plus the false hits the gate admits | 16 |
n_slots |
receivers held; the population plus release headroom | 12 |
threads |
the workers the searcher's tiles and the receivers run across: 1 is serial, ≤ 0 takes the online core count. The result is bit-identical at any count | 1 |
carrier_freq_hz |
the RF carrier the Doppler is physically coupled to; told, the searcher walks its blocks by each tile's code rate, advances the seed by half a dwell's drift, and the receivers aid their code loops from the carrier (0 = uncoupled) | 0 |
lost_confirm_s |
the release rule's interval: both flags down this long is lost; longer than the longest fade the link must ride | 2.0 |
max_emitter_on_time_secs |
the on-time cap: a slot held this long is released and its emitter re-acquired; 0 never releases for time | 900 |
segments, sps, differential |
the receivers' despreader partials per code period, the demodulator's samples per symbol, differential decoding | 4, 8, 0 |
gain, pullin_intervals |
the receivers' correction: chips per chip of the interval-mean discriminator after the pull-in, and the intervals at gain 1 before it | 0.125, 4 |
Anything the constructor cannot accept — an empty code, a non-positive
rate, gain outside (0, 1], a searcher whose depth or Doppler bin a
cell receiver cannot take — is a ValueError naming the whole admissible
set, not a MemoryError.
One knob is a method, because it is decided after the population is
known: set_event_log(log) attaches the run's log (None detaches).
A physically-coupled carrier (carrier_freq_hz > 0) is the setting that
matters most for a moving emitter: without it a 50 kHz Doppler smears the
searcher's coherent block by three chips and the receivers' code loops
have no aid.
The call surface¶
push() is the whole cycle; everything else reads back or manages the
object's life.
push(x) -> inttakes one block ofcomplex64and returns the number of assigned slots after it. In order: the searcher; the table refreshed; every hit in a live row's zone dropped as that emitter's own; each survivor seeded into a free slot or counteddropped; every receiver fed, across the pool's threads; every receiver that reports lost, or has held its slot past the on-time cap, released. Any block size is accepted — a hit decided inside the block is referred back to the block's start before it seeds, on the dilated clock when the carrier is known.status(slot) -> PoolSlotreturns one record by value, allocation free. A slot outside[0, n_slots)returns a zero record withstate == -1rather than raising. Read it after every push; it is notget_state(), which is the bytes for resuming the pool elsewhere.symbols(slot) -> NDArray[complex64]is what the slot's receiver decided on the last push, copied out of the pool's own buffer, which the nextpush()overwrites. Empty while the slot is idle, refining or lost. Passout=to write into an array you own.symbols_max_out()is the per-slot capacity — grown with the largest block pushed so far, and 0 before the first push. A slot outside[0, n_slots)raisesValueError.reset()releases every slot and starts over: the searcher reset, every receiver back to idle, the table cleared, the counters zeroed. An attached log stays attached and nothing is logged — a reset is the holder's decision, not an emitter's transition.set_event_log(log)attaches or (withNone) detaches the log. It is borrowed, never owned: the holder opens, finalizes and closes it. A log that has already failed keeps failing, and the pool counts the transition either way.state_bytes()/get_state() -> bytes/set_state(blob)are the checkpoint triplet. The blob carries the pool's counters and table, the searcher's state and every receiver's, each self-validating; the geometry, the slot count, the carrier and the attached log are restored by the constructor instead, and a blob from a pool of another slot count is rejected. The last push's symbols are scratch and do not survive. A mid-stream split resumes bit for bit (Checkpoint & Resume).destroy(), or awithblock, releases the C resources at a definite point. Otherwise they go at collection.
The counters are properties: n_slots, n_assigned, dropped (hits
that found no free slot), events (transitions since create or reset,
logged or not), and samples_consumed — the stream position every event
is stamped at. Two more report what the searcher chose from the geometry:
doppler_res_hz, the width of a Doppler bin and so the resolution a
seed's Doppler is reported at, and coherent_bins, the depth D.
status() returns a PoolSlot, a named tuple of seventeen fields
(src/doppler/dsss/dsss.pyi):
| field | meaning |
|---|---|
slot |
the index asked for |
assigned |
1 while a receiver holds an emitter |
state |
the receiver's state: 1 refining, 2 tracking, 3 idle, 4 lost; −1 for a slot that does not exist |
seed_sample |
stream position the row was assigned at |
seed_chip_phase, seed_doppler_hz, seed_cn0_dbhz |
the seed, verbatim: chips, Hz, dB-Hz |
doppler_hz, chip_phase, code_rate |
where the emitter is now: Hz, chips, chips advanced per nominal chip |
cn0_dbhz_est |
the C/N0 estimate, dB-Hz |
code_locked, locked, lock_metric |
the presence flag, the symbol-lock flag, and the statistic the second is a threshold on |
state_samples, both_down_samples, assigned_samples |
three clocks in input samples: since the state was entered, since both flags dropped, since the row was assigned |
The event log is a flat, tail-able JSON-lines file while the run is
live and a SigMF sidecar when finalized; every line carries
core:sample_start, core:label and the slot's doppler:* fields, plus
reason (lost or on_time) on a released. Replay and live runs
produce identical records, because nothing below the pool sees a time.
A worked run¶
The gallery page walks one lifecycle end to end with the figure; the regions below are its tested source. The geometry — the code, the rates, the searcher's span and a release interval short enough to watch:
import numpy as np
from doppler.dsss import AsyncDsssPool
from doppler.telemetry import EventLog
from doppler.wfm import Gold, Synth, _SynthEngine, wfm_awgn_amplitude
SF = 1023
CHIP_RATE = 5.0e6
SPC = 2
FS = CHIP_RATE * SPC
SYM_RATE = 2700.0
TE = SF * SPC # one code epoch, the push block
CN0_DBHZ = 47.0
DU = 6000.0 # the searcher's span: +-6 kHz
# The code-only window the searcher aligns its blocks inside: 20 symbols of
# every 270 (ten frames a second; the operating point's is 450 of 4950),
# holding the 31 whole epochs a depth of 16 needs at any chip phase -- rows
# of 305 Hz, inside the cell receivers' pull-in (the pool refuses a
# shallower searcher).
W_SYM, F_SYM, CODE_ONLY_EPOCHS = 20, 270, 31
LOST_S = 0.3 # the release interval, short enough to see
N_SLOTS = 4
EMITTERS = { # name: (Doppler Hz, code phase at sample 0 in chips, seed)
"A": (1500.0, 0, 1),
"B": (-3500.0, 900, 2),
}
ON_S, OFF_S = 1.0, 3.0 * LOST_S # A always on; B on, off, on
CODE = np.asarray(Gold().generate(SF)).astype(np.uint8)
The stimulus is the shipped waveform: two emitters on one code at their own Doppler and code phase, one leaving and returning, noise at the sum.
def emitter(doppler_hz: float, chip0: int, seed: int) -> _SynthEngine:
"""One emitter: the shipped continuous DSSS at a carrier offset, clean,
with the waveform's code-only window, its code `chip0` chips in at the
stream's first sample (a burn-in the caller discards) -- two emitters
must differ in code phase as well as Doppler, since one phase is one
peak to the searcher. The engine rather than ``Synth``: the window is
the engine's alone (#1294)."""
syn = _SynthEngine(
type="dsss",
fs=FS,
freq=doppler_hz,
snr=100.0, # clean: the noise is added once, at the sum
sps=SPC,
seed=seed,
)
syn.set_dsss_cont(CODE, CHIP_RATE / SYM_RATE, data="prbs")
syn.set_dsss_window(W_SYM, F_SYM)
if chip0:
syn.steps(chip0 * SPC)
return syn
def stimulus(seed: int = 7):
"""Both emitters on for ON_S, B off for OFF_S (its synth keeps running:
coming back is not restarting), both on again; noise at the sum.
Returns the capture and B's on-air mask per epoch."""
a = emitter(*EMITTERS["A"])
b = emitter(*EMITTERS["B"])
n_on, n_off = int(ON_S * FS / TE), int(OFF_S * FS / TE)
on_b = np.array([1] * n_on + [0] * n_off + [1] * n_on, dtype=bool)
blocks = []
for on in on_b:
sa = a.steps(TE)
sb = b.steps(TE)
blocks.append(sa + sb if on else sa)
x = np.concatenate(blocks)
# C/N0 referred to fs; the Synth emits unit total power, the amplitude
# is per component, and the sqrt(2) bridges the two conventions.
amp = float(wfm_awgn_amplitude(CN0_DBHZ - 10.0 * np.log10(FS), 1.0))
noise = Synth(type="noise", fs=FS, seed=seed).steps(len(x))
return (x + amp * np.sqrt(2.0) * noise).astype(np.complex64), on_b
The pool, fed one epoch per push, with the log attached and every slot's
status read after each push. The owner() helper is the identification
above rather than the pool's own rule: it matches a slot's seed against a
known emitter on both coordinates, which a test can do because it has the
truth and the pool cannot:
def owner(pool: AsyncDsssPool, slot: int) -> str | None:
"""Which emitter a slot's SEED is -- row and chip phase -- or None
for a false alarm (a noise seed at another phase)."""
r = pool.status(slot)
if not r.assigned:
return None
for name, (f, chip0, _) in EMITTERS.items():
truth = (r.seed_sample / SPC + chip0) % SF
dc = abs(r.seed_chip_phase - truth)
dc = min(dc, SF - dc)
if abs(r.seed_doppler_hz - f) <= pool.doppler_res_hz and dc <= 1.0:
return name
return "false alarm"
def run(x: np.ndarray, events_path: str):
pool = AsyncDsssPool(
CODE,
chip_rate=CHIP_RATE,
symbol_rate=SYM_RATE,
spc=SPC,
cn0_dbhz=CN0_DBHZ,
pfa=1e-3,
doppler_uncertainty=DU,
code_only_epochs=CODE_ONLY_EPOCHS,
n_slots=N_SLOTS,
lost_confirm_s=LOST_S,
threads=1,
)
log = EventLog(events_path)
pool.set_event_log(log)
n_ep = len(x) // TE
who = np.full((N_SLOTS, n_ep), "", dtype=object) # owner per epoch
dopp = np.full((N_SLOTS, n_ep), np.nan) # live Doppler while tracking
n_syms = dict.fromkeys(EMITTERS, 0)
for k in range(n_ep):
pool.push(x[k * TE : (k + 1) * TE])
for i in range(N_SLOTS):
o = owner(pool, i)
r = pool.status(i)
who[i, k] = o or ""
if o in n_syms:
n_syms[o] += len(pool.symbols(i))
if r.assigned and r.state == 2: # tracking
dopp[i, k] = r.doppler_hz
pool.set_event_log(None)
log.close()
with open(events_path) as fh:
events = [json.loads(line) for line in fh]
assert len(events) == pool.events, "every transition reached the log"
return who, dopp, n_syms, events
In C the same run is native/examples/async_dsss_pool_demo.c, which also
shows what the binding hides: the symbol buffer sized after the first
push, and the borrowed log the caller closes.
Performance¶
The certified envelope — every limit a caller may rely on, and the three
findings still open — is the pool's validation report,
src/doppler/dsss/tests/validation/async_dsss_pool/results.md
(on GitHub).
It is generated by the validate.py beside it from the C implementation
through its own binding; nothing in it is modelled. 15 of 15 limits
hold, and of its ten findings F5, F9 and F10 are open. The numbers here
are quoted from it and from the ten-emitter soak
(native/validation/async_dsss_pool_soak.c), not re-derived.
The lifecycle, at the operating point. The certified run is two
emitters for 16 s at 45 dB-Hz; the soak is ten emitters arriving and
leaving, 120 s at 45 dB-Hz and 600 s at the 40 dB-Hz floor. All three on
D = 154, ±50 kHz, sixteen hits per dwell, twelve slots, the carrier
told, a 2 s release interval, twenty threads.
| quantity | certified run, 45 dB-Hz | soak, 45 dB-Hz | soak, 40 dB-Hz |
|---|---|---|---|
| arrival → tracking, mean / max | 0.11 / 0.12 s | 0.09 / 0.21 s | 0.41 / 1.76 s |
| tracking with code lock, of held blocks | 0.9852 | 0.9982 | 0.9954 |
| with symbol lock, of held blocks | 0.9790 | 0.9969 | 0.9808 |
| departure → release, max | 2.02 s | 2.15 s | 2.04 s |
| emitters missed / released while on air | 0 / 0 | 0 / 0 | 0 / 0 |
| blocks with two receivers on one emitter | 0 | 0 | 0 |
A slot holds an arriving emitter well before it tracks it: 0.02 / 0.03 / 0.04 s min / mean / max in the certified run, the rest of the time to tracking being the pull-in. The certified floor on the two lock fractions is 98% of held blocks with code lock and 95% with symbol lock.
Design to the release rule's two intervals, not one: the code flag returns on noise at about 0.004 per second, and one return inside the interval restarts the clock once — of the order of one departure in a hundred. The seed a receiver pulls in from is coarse by design: worst observed 781 Hz and 0.17 chip in the certified run, because at 45 dB-Hz the first hit on an emitter is usually a smeared copy from its own data blocks rather than from its code-only window.
Cost. A number here is a multiple of real time: above 1 means the stage takes longer than the signal it consumed.
| stage | × real time |
|---|---|
inside push(), two emitters, twenty threads |
2.95 |
the chain: the shipped DDC from 13 MSa/s, then that push() |
3.16 |
| the same chain at a 30 MSa/s front end | 7.29 |
| the chain, the whole ten-emitter population at 13 MSa/s | 4.12 |
| the same, at 30 MSa/s | 9.51 |
the block searcher alone, D = 154 over ±50 kHz, four threads |
1.6 |
the block searcher alone, D = 154 over ±5 kHz, four threads |
0.35 |
The two ten-emitter figures were taken on the receiver flavour this pool
replaced, measured side by side against it at 2.954 of a core per second
of signal against 2.941, so they carry over. The widest operating point
does not keep up on one twenty-thread box, and the stage that owns
the excess is the searcher's depth, not the receivers — one receiver costs about 44 ns per sample, twelve of them a
fraction of the searcher's block. The two levers are the Doppler span —
pre-compensating to ±5 kHz is a 4.6× saving in the searcher — and more
threads: the block searcher's fan is 92% of its work at D = 154, so
four threads buy 3.2× and eight 4.2×, and every thread count gives
bit-identical records.
Memory. The searcher's block and surface at D = 154 are about half
a gigabyte (501.6 MiB of heap after warm-up, 254 MiB resident). Nothing
allocates per push once a block size has been seen, and each receiver
builds its track chain on its first seed — a first-use step, not growth:
once every slot has been used, the heap moves by +0.1 KiB over 120 s and
+0.3 KiB over 600 s.
Sizing. Slots are the population, plus the receivers still inside a confirm interval on emitters that have just left, plus the false alarms (one slot each for one interval): twelve for ten emitters at a departure a minute and a two-second interval. The hit list is the population plus the false hits the gate admits per dwell; sixteen for ten.
What it does not do¶
- It does not cancel a strong emitter to find a weak one under its sidelobes: the list branch ships for a power spread inside the floor (about 13 dB in the operating case, 21 inside the code-only window); a wider spread needs the cancellation branch, designed and not built.
- Two emitters within a chip of each other on the code axis are one while the first is live: the zone is the code axis alone.
- A neighbour within about a kilohertz crossing a departed receiver's code phase at a chip or two a second looks like that emitter's return to the receiver; the pool, which knows the neighbour has a slot, is where it can be told apart, and it is not yet (#1275).