Python Error-Rate Measurement API¶
The doppler.ber module is the instrument for an error rate. An error rate
is a measurement, and like any measurement it can be confidently wrong — this
module owns the three things that make it defensible, so no caller has to
re-derive them:
- Is the window SETTLED? —
ber_settle_symsandber_settle_from. - Have enough ERRORS been counted? —
BerMeterstops on an error target, not a symbol count. - Does the answer make sense? —
ber_evm_db,ber_evm_scatter_floor_dbandber_theory_sercross-check it against measurements that cannot fail the same way.
See the worked example in the gallery: Measuring an Error Rate, Defensibly.
Stop on errors, not on symbols¶
Under inverse binomial sampling — fix the number of errors r, let the
symbol count N be the random variable — the relative standard error is
1/sqrt(r), a function of the error count alone. The error target is the
precision, chosen up front and independent of the rate about to be measured.
Stop on a fixed symbol count instead and precision depends on the very quantity under test, degrading silently as the rate falls: 20 000 symbols at a SER of 1e-3 yields ~20 errors and ~22% relative error, which reads as real seed-to-seed variation in a receiver and is not.
>>> from doppler.ber import BerMeter, ber_theory_ser, ber_esn0_db_for_ser
>>> round(ber_esn0_db_for_ser(4, 1e-3), 2) # QPSK's SER=1e-3 anchor
10.35
>>> round(ber_theory_ser(2, 10 ** 0.96), 7) # BPSK at 9.6 dB
9.7e-06
The alignment is detected, never searched¶
BerMeter.align() correlates a known marker — a sync word, one period of
a spreading code, or (in a simulation, where truth exists) a stretch of the
truth sequence — and gates the correlation peak on a false-alarm probability.
It deliberately does not search for the lag and rotation that minimise the
error count. That search is an optimisation over the answer rather than a
measurement of the receiver, and it fails both ways: a wide search on a short
window finds a lucky low-error alignment on garbage, and a narrow one misses the
true alignment on a healthy stream and reports chance. align() returns False
when the marker cannot identify an alignment, and the marker's own symbols are
excluded from scoring so they cannot also flatter the rate.
The interval is exact — assert on lo¶
ser(), ber() and interval() return a BerInterval record with fields
p_hat, lo, hi, rel, conf, errors, symbols. The limits are the
exact Gamma/chi-square interval for inverse binomial sampling, not a normal
approximation, so they stay honest down to a single error; the quantiles come
from detection.det_threshold_noncoherent, doppler's own
inverse regularized incomplete gamma, rather than a second numeric kernel.
Two things the fixed-N habit gets wrong and this does not: the naive r/N is
biased (the unbiased estimator is (r-1)/(N-1)), and the interval is
asymmetric. Compare the interval's lower limit lo against a spec, never
p_hat — that is the form counting noise cannot flake.
The EVM floor is M-dependent¶
ber_evm_scatter_floor_db gives what a
completely destroyed constellation reads: 2 - 2 sin(pi/M)/(pi/M), i.e.
−1.4 dB at BPSK, −7.0 at QPSK, −12.9 at 8PSK. The familiar "a scattered
constellation reads about 0 dB" is the BPSK limit only — at 8PSK a stream with
no carrier recovery at all reads the same −12.9 dB a perfectly healthy 13 dB
link does. State any fixed EVM threshold against this floor, never against
zero. Not to be confused with the noise floor -(Es/N0).
>>> from doppler.ber import ber_evm_scatter_floor_db
>>> [round(ber_evm_scatter_floor_db(m), 2) for m in (2, 4, 8)]
[-1.39, -7.0, -12.92]
BerMeter
¶
Create a meter for constellation m stopping at target_errors.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
m
|
int
|
Constellation order (2, 4, 8). |
4
|
target_errors
|
int
|
Inverse-binomial stop condition; 0 selects 200. |
200
|
conf
|
float
|
Two-sided confidence level; 0 selects 0.99. |
0.99
|
Examples:
Create with defaults:
skipped
property
¶
Symbols skipped: covered by a marker occurrence, or with a truth index outside the installed sequence. Marker symbols are excluded on purpose -- they are known, so scoring them would flatter the rate with symbols that had no chance of being wrong.
enough
property
¶
True once target_errors have been counted. THE stop condition for a measurement loop: fix the ERROR count and let the symbol count fall out (inverse binomial sampling), so the relative standard error is 1/sqrt(r) -- a function of the error count alone. Stopping on a fixed symbol count instead makes the precision depend on the very rate being measured: 20000 symbols at SER 1e-3 gives ~20 errors and ~22% relative error, which reads as real seed-to-seed variation in the receiver and is not.
phase
property
¶
Absolute residual constellation rotation from the last align(), radians. The marker resolves this outright, so there is no M-fold ambiguity left to search over.
align_stat
property
¶
Detection statistic at the correlation peak, in threshold units.
align_margin_db
property
¶
20*log10(stat/threshold) -- headroom over the false-alarm gate. Negative means the alignment was NOT detected.
align_runner_db
property
¶
Peak over the best runner-up lag, dB. Under ~3 dB the peak is ambiguous and the alignment is rejected.
align_slips
property
¶
Occurrences whose phase disagreed with the peak by more than half a decision sector -- cycle slips.
align_saturated
property
¶
True when the peak sat on an edge of the lag search: lag_span is too small and the result is not trustworthy.
align_ok
property
¶
True when the last align() was detected, unambiguous and unsaturated. score() is meaningless unless this is True.
reset
¶
Zero the running counters; keep the configuration and the truth.
Returns the meter to a fresh count while preserving m, the error target, the confidence level and the installed truth sequence, so one meter can measure independent captures back to back without reinstalling truth. The last detected alignment is left untouched; call align() again for the next capture before scoring it.
Examples:
>>> import numpy as np
>>> from doppler.ber import BerMeter
>>> rng = np.random.default_rng(0)
>>> truth = rng.integers(0, 4, size=400).astype(np.uint8)
>>> ang = 2 * np.pi * truth / 4 + np.pi / 4
>>> rx = np.exp(1j * ang).astype(np.complex64)
>>> met = BerMeter(m=4)
>>> met.set_truth(truth)
>>> met.align(rx, n_marker=64)
1
>>> met.score(rx, hi=truth.size)
336
>>> met.symbols
336
>>> met.reset() # reuse the meter for the next capture
>>> (met.errors, met.symbols)
(0, 0)
set_truth
¶
Install the transmitted symbol INDICES (0..m-1, not Gray labels) this meter scores against. Copied, so the caller's buffer need not outlive the call, and reused across every burst. Raises ValueError if any index is outside 0..m-1.
Copied, so the caller's buffer need not outlive the call, and reused
across every burst. Values are symbol INDICES in 0..m-1 (not Gray
labels): the meter Gray-encodes each side itself when it counts bit
errors, so handing it Gray labels would double-encode and inflate the
rate.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
truth
|
NDArray[uint8]
|
Transmitted symbol indices, each in |
required |
Examples:
>>> import numpy as np
>>> from doppler.ber import BerMeter
>>> met = BerMeter(m=4)
>>> truth = np.array(
... [0, 3, 1, 2, 2, 0], dtype=np.uint8) # indices, 0..3
>>> met.set_truth(truth)
>>> met.set_truth(np.array([9], dtype=np.uint8)) # 9 is not in 0..3
Traceback (most recent call last):
ValueError: set_truth failed (rc=-4)
align
¶
align(
rx: NDArray[complex64],
t0: int = 0,
n_marker: int = 0,
period: int = 0,
lag_span: int = 0,
pfa: float = 0.0,
) -> int
Detect where the recovered symbols sit against truth, returning a
BerAlign. The alignment is DETECTED by correlating a known marker --
truth[t0 : t0+n_marker], optionally repeating every period symbols --
and gated by a false-alarm probability, NOT searched by minimising the
error count. That distinction is the whole point: a min-over-(lag,
rotation) search is an optimisation over the answer, and it both
false-passes on a lucky alignment and false-floors when the true lag
falls outside the span. A marker too short to identify an alignment
returns ok=False rather than a plausible wrong lag. Repeats are
combined non-coherently, which raises the processing gain and exposes
cycle slips.
Correlates the known marker truth[t0 .. t0 + n_marker) against rx
over a span of lags, gates the peak with a false-alarm probability, and
stores the winning lag, absolute carrier phase and marker geometry on
the meter so score() later uses exactly this detection — never a lag
searched to minimise the error count. The peak's phase is the ABSOLUTE
constellation rotation, so no M-fold ambiguity is left to resolve; a
marker too short to clear the gate reports failure rather than a
plausible wrong lag. Read the outcome through align_ok, lag, phase and
align_margin_db.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rx
|
NDArray[complex64]
|
Recovered symbols to align against the truth. |
required |
t0
|
int
|
Truth index of the marker's first occurrence. |
0
|
n_marker
|
int
|
Marker length in symbols; 0 selects BER_SYNC_SYMS. |
0
|
period
|
int
|
Repeat period in symbols; 0 for a single occurrence. |
0
|
lag_span
|
int
|
Search half-width in symbols; 0 selects BER_LAG_SPAN. |
0
|
pfa
|
float
|
Whole-search false-alarm probability; 0 selects 1e-6. |
0.0
|
Returns:
| Type | Description |
|---|---|
int
|
1 when the detection passed its false-alarm gate, else 0. |
Examples:
>>> import numpy as np
>>> from doppler.ber import BerMeter
>>> rng = np.random.default_rng(0)
>>> truth = rng.integers(0, 4, size=600).astype(np.uint8)
>>> ang = 2 * np.pi * truth / 4 + np.pi / 4
>>> rx = np.exp(1j * ang).astype(np.complex64)
>>> met = BerMeter(m=4)
>>> met.set_truth(truth)
>>> met.align(rx, n_marker=64) # correlate a 64-symbol marker
1
>>> met.lag, met.align_ok # detected, so score() is valid
(0, 1)
score
¶
Score rx[lo:hi] against the truth and accumulate; returns the
symbols scored. Uses the supplied alignment VERBATIM -- no lag search,
no rotation search, no minimisation of any kind over the answer. Uses
the alignment align() last detected, together with the marker geometry
that found it, so a measurement cannot be handed an alignment belonging
to a different burst. Symbols covered by a marker occurrence are
excluded, as are any whose truth index falls outside the installed
sequence; both land in skipped.
Demodulates each symbol in the window under the alignment the last align() detected — its lag and absolute phase — and tallies symbol and Gray-coded bit errors against the installed truth. The alignment is used VERBATIM: no lag search, no rotation search, no minimisation of any kind over the answer. Symbols covered by a marker occurrence are excluded, as are any whose truth index falls outside the installed sequence; both land in skipped.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rx
|
NDArray[complex64]
|
Recovered symbols to score. |
required |
lo
|
int
|
First symbol index to score (inclusive). |
0
|
hi
|
int
|
One past the last symbol index to score; clamped to rx_len. |
0
|
Returns:
| Type | Description |
|---|---|
int
|
Symbols actually scored (window length minus skipped symbols). |
Examples:
>>> import numpy as np
>>> from doppler.ber import BerMeter
>>> rng = np.random.default_rng(1)
>>> truth = rng.integers(0, 4, size=800).astype(np.uint8)
>>> ang = 2 * np.pi * truth / 4 + np.pi / 4
>>> rx = np.exp(1j * ang).astype(np.complex64)
>>> met = BerMeter(m=4)
>>> met.set_truth(truth)
>>> met.align(rx, n_marker=64)
1
>>> met.score(rx, hi=truth.size) # the 64 marker symbols are excluded
736
>>> met.errors, met.skipped
(0, 64)
ser
¶
Symbol error rate with its EXACT confidence interval, as a
BerInterval. Assert on lo, never on p_hat: comparing the lower
limit against a spec is the form that cannot flake on counting noise.
The interval is the Gamma/chi-square one for inverse binomial sampling
-- no normal approximation anywhere, so it stays honest at the small
error counts where a Wald interval is worst -- and its quantiles come
from doppler's own detection primitives rather than a second copy of an
incomplete-gamma kernel.
Divides the accumulated symbol-error count by the symbols scored and wraps it in the exact Gamma/chi-square interval for inverse binomial sampling — no normal approximation anywhere. Assert on lo, never on p_hat: comparing the lower limit against a spec is the form that cannot flake on counting noise.
Returns:
| Type | Description |
|---|---|
BerInterval
|
A BerInterval record — |
Examples:
>>> import numpy as np
>>> from doppler.ber import BerMeter
>>> rng = np.random.default_rng(1)
>>> truth = rng.integers(0, 4, size=800).astype(np.uint8)
>>> ang = 2 * np.pi * truth / 4 + np.pi / 4
>>> rx = np.exp(1j * ang).astype(np.complex64)
>>> rx[200:260:5] *= -1 # corrupt 12 symbols (pi rotation)
>>> met = BerMeter(m=4)
>>> met.set_truth(truth)
>>> met.align(rx, n_marker=64)
1
>>> _ = met.score(rx, hi=truth.size)
>>> r = met.ser()
>>> r.errors, r.symbols
(12, 736)
>>> round(r.lo, 4) # assert on lo, never on p_hat
0.0067
ber
¶
Gray-coded bit error rate with its EXACT confidence interval, as a BerInterval. Same statistics as ser(), counted over bits.
The same exact statistics as ser(), counted over Gray-coded bits rather than symbols, so a QPSK/8PSK symbol error contributes as many bit errors as its Gray labels differ by. Assert on lo, never on p_hat.
Returns:
| Type | Description |
|---|---|
BerInterval
|
A BerInterval record — |
Examples:
>>> import numpy as np
>>> from doppler.ber import BerMeter
>>> rng = np.random.default_rng(1)
>>> truth = rng.integers(0, 4, size=800).astype(np.uint8)
>>> ang = 2 * np.pi * truth / 4 + np.pi / 4
>>> rx = np.exp(1j * ang).astype(np.complex64)
>>> rx[200:260:5] *= -1 # corrupt 12 symbols
>>> met = BerMeter(m=4)
>>> met.set_truth(truth)
>>> met.align(rx, n_marker=64)
1
>>> _ = met.score(rx, hi=truth.size)
>>> r = met.ber() # as ser(), but over bits
>>> r.errors, r.symbols
(24, 1472)
>>> round(r.lo, 4)
0.009
interval
¶
The exact confidence interval for error/trial counts gathered
elsewhere, at this meter's confidence level. Same statistics as ser():
the Gamma/chi-square interval for inverse binomial sampling, with
quantiles from doppler's own inverse regularized incomplete gamma
rather than a normal approximation, so it stays honest at the small
error counts where a Wald interval is worst. Assert on lo, never on
p_hat.
The pure-function face of the meter's statistics, at this meter's own confidence level: hand it any error and trial counts and it returns the same exact Gamma/chi-square interval ser() would, with quantiles from doppler's own inverse regularized incomplete gamma rather than a normal approximation, so it stays honest at the small error counts where a Wald interval is worst. Assert on lo, never on p_hat.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
errors
|
int
|
Errors counted, |
required |
symbols
|
int
|
Trials counted, |
required |
Returns:
| Type | Description |
|---|---|
BerInterval
|
A BerInterval record — |
Examples:
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 BerMeter 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 exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.
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 BerMeter 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; the object keeps the parameters it
was constructed with. Length is validated against state_bytes()
before the blob is handed to the C core, and the core may reject it as
well.
Raises TypeError if blob is not bytes, ValueError if its
length differs from state_bytes() or the core rejects it, and
RuntimeError if the BerMeter 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 BerMeter be used in a with statement so its C resources are
released deterministically on exit rather than at collection time.
Returns:
| Type | Description |
|---|---|
BerMeter
|
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 BerMeter.
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. |
...
|
ber_theory_ser
¶
Coherent M-PSK symbol error rate at matched-filter Es/N0 (LINEAR, not dB). BPSK Q(sqrt(2 Es/N0)); QPSK 2Q(sqrt(Es/N0)); 8PSK 2Q(sqrt(2 Es/N0) sin(pi/8)). This is a COHERENT bound: a differentially-decoded rate is ~2x it, so pairing a differential measurement with this curve invents a factor of two of implementation loss.
BPSK: Q(sqrt(2 Es/N0)), QPSK: 2 Q(sqrt(Es/N0)), 8PSK: 2 Q(sqrt(2
Es/N0) sin(pi/8)) — the nearest-neighbour union bound, tight to well
under a percent at any Es/N0 worth testing at.
This is a COHERENT bound. A differentially-decoded rate is ~2x it, because a differential decision fails when either of its two symbols is wrong (measured 1.88-2.11 across M and both receiver paths). Pairing a differential measurement with this curve invents a factor of two of "implementation loss".
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
m
|
int
|
Input. |
required |
esn0
|
float
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Output. |
ber_theory_ber
¶
Coherent GRAY-coded M-PSK bit error rate at Es/N0 (LINEAR). BPSK and Gray QPSK are exactly Q(sqrt(2 Eb/N0)); 8PSK uses SER/log2(M).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
m
|
int
|
Input. |
required |
esn0
|
float
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Output. |
ber_esn0_db_for_ser
¶
Es/N0 (dB) at which the coherent bound equals ser. How an
implementation loss is quoted honestly: convert the MEASURED rate to
the Es/N0 theory would need to produce it, and subtract. A loss in dB
is comparable across M and across operating points; a ratio of rates is
not.
How an implementation loss is quoted honestly: convert the MEASURED rate to the Es/N0 theory would need to produce it, and subtract. A loss in dB is comparable across M and across operating points; a ratio of rates is not.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
m
|
int
|
Input. |
required |
ser
|
float
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Output. |
ber_evm_db
¶
Self-referenced EVM (dB) over an EXPLICIT window [lo, hi): each symbol against the stream's OWN hard decision, with the constellation rotation estimated from the data. References neither the transmitted symbols nor a lag, so it cannot be fooled by an alignment search. A locked matched-filter output reads EVM_dB ~ -(Es/N0)_dB (an I/Q-plane quantity -- no factor of two; quoting one flatters the result by 3 dB). Read it against ber_evm_scatter_floor_db(m), NEVER against 0 dB. The window is explicit because BER and EVM must be measured on the SAME one: a convenience back-half default scores a different window than the error rate did, and the two eventually disagree in a way that reads as a receiver defect rather than the harness bug it is. Returns 0.0 for a window under 20 symbols.
Scores each symbol against the stream's OWN hard decision, with the
constellation rotation estimated from the data — so it references
neither the transmitted symbols nor a lag, and cannot be fooled by an
alignment search. At a matched-filter output the error vector IS the
complex noise, so a locked stream reads EVM[dB] ~ -(Es/N0)[dB]. EVM
is an I/Q-plane quantity: there is no factor of two — that belongs to
an I-only measurement, and quoting it flatters the result by 3 dB.
Pass the real m, and read the result against
ber_evm_scatter_floor_db(m), never against 0 dB.
The window is EXPLICIT because BER and EVM must be measured on the SAME one. A convenience "back half" default silently scores a different window than the error rate did, and the two eventually disagree in a way that reads as a receiver defect rather than the harness bug it is.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rx
|
NDArray[complex64]
|
Input. |
required |
lo
|
int
|
Input. |
0
|
hi
|
int
|
Input. |
0
|
m
|
int
|
Input. |
4
|
Returns:
| Type | Description |
|---|---|
float
|
EVM in dB, or 0.0 ("no lock") for a window under 20 symbols. |
ber_evm_scatter_floor_db
¶
EVM (dB) of an M-PSK constellation at a UNIFORMLY RANDOM rotation --
the FLOOR of a self-referenced EVM, i.e. what a completely destroyed
constellation reads: -1.4 dB at BPSK, -7.0 at QPSK, -12.9 at 8PSK. ANY
fixed EVM threshold must be stated against this, never against 0 dB:
'scattered reads ~0 dB' is the BPSK limit only, and at 8PSK a stream
with no carrier recovery reads the same -12.9 dB a healthy 13 dB link
does, so a < -12.0 assertion is satisfied by pure noise. The room
between 'on the bound at the SER=1e-3 anchor' and 'completely broken'
collapses as M grows (5.4 / 3.3 / 2.8 dB), so at high M the EVM cannot
carry a verdict alone. Not to be confused with the NOISE floor
-(Es/N0).
The FLOOR of a self-referenced EVM: what a completely destroyed
constant-modulus constellation reads. Slicing a unit-modulus point at a
uniformly random phase to its nearest of M neighbours leaves E|e|^2 =
2 - 2 sin(pi/M)/(pi/M): -1.4 dB at BPSK, -7.0 at QPSK, -12.9 at
8PSK.
Any fixed EVM threshold must be stated against this, never against 0
dB. "Scattered reads ~0 dB" is the BPSK limit only. At 8PSK a stream
with no carrier recovery at all reads -12.9 dB — which is also what a
perfectly healthy 13 dB link reads — so a < -12.0 assertion is
satisfied by pure noise. That was live in this repo's own receiver
tests until 2026-07-27. The room between "on the bound at the SER=1e-3
anchor" and "completely broken" collapses as M grows: 5.4 dB at BPSK,
3.3 at QPSK, 2.8 at 8PSK, so at high M the EVM cannot carry a verdict
by itself.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
m
|
int
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Output. |
ber_settle_syms
¶
Symbols to discard before a steady-state measurement means anything: 2*(5/bn_timing + 5/bn_carrier). Three factors, and skipping any produces a confident wrong number -- 5/Bn per loop is the standard second-order settling time (in SYMBOLS, since both bn are symbol-rate normalised); the two budgets ADD because the loops are cascaded (the carrier discriminator reads the on-time strobe, so it cannot converge until timing has); and the sum DOUBLES for joint tracking. This is a FLOOR, not the answer: take the max of it and every lock indicator the receiver publishes, plus the handover instant again if one is enabled. Pass a loop's bn as 0 if it is not running.
2 * (5/bn_timing + 5/bn_carrier). Three factors, and skipping any of
them produces a confident wrong number: 5/Bn per loop is the standard
second-order settling time (in symbols, because both bn are
normalised to the SYMBOL rate); the two budgets ADD because the loops
are CASCADED (the carrier discriminator reads the on-time strobe, so it
cannot converge until timing has); and the sum DOUBLES for joint
tracking, where each loop sees the other's transient as a disturbance.
This is a floor, not the answer — take the max of it and every lock
indicator the receiver publishes, plus the handover instant again if
one is enabled. Pass a loop's bn as 0 if it is not running.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
bn_timing
|
float
|
Input. |
required |
bn_carrier
|
float
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Output. |
ber_settle_from
¶
ber_settle_from(
budget: int,
timing_lock: int = -1,
carrier_lock: int = -1,
handover: int = -1,
) -> int
Where a steady-state measurement may start: max(budget, timing lock, carrier lock, handover + budget). The analytic budget and the receiver's own indicators are both fallible in the SAME direction, so whichever settles last decides. A handover settles last of all -- it fires on carrier lock plus a warmup, strictly after every other term, and the decision-directed loop then has its own transient, so it contributes its instant PLUS the budget again (measured on 8PSK: handover at symbol 2525 against a 2000-symbol budget, SER 5.95x the coherent bound from 2000 versus 1.68x from 4525). Pass -1 for an indicator the receiver does not publish, which is what ber_lock_symbol() returns for 'never locked'. A -1 timing or carrier lock means there is NO valid steady-state window -- check that yourself before trusting the return; a -1 handover is not a failure, since a pure-NDA receiver never publishes one.
The POLICY for where a steady-state window may start, in one place:
max(budget, timing lock, carrier lock, handover + budget). The
analytic budget and the receiver's own indicators are both fallible in
the SAME direction, so whichever settles last decides.
A handover settles last of all. With acq_to_track on it fires on
carrier lock plus a warmup — strictly after the budget and after every
lock indicator — and the decision-directed loop then has its own
transient, so it contributes its instant + the budget again. Measured
on 8PSK at its SER=1e-3 anchor: handover at symbol 2525 against a
2000-symbol budget, SER 5.95x the coherent bound measured from 2000 and
1.68x from 4525.
Pass -1 for any indicator the receiver does not publish (which is what ber_lock_symbol() returns for "never locked"). A -1 timing or carrier lock means there is NO valid steady-state window — check that yourself before trusting the return; a -1 handover is not a failure, because a pure-NDA receiver never publishes one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
budget
|
int
|
ber_settle_syms() of the loops in use. |
required |
timing_lock
|
int
|
ber_lock_symbol() of the timing flag, or -1. |
-1
|
carrier_lock
|
int
|
ber_lock_symbol() of the carrier flag, or -1. |
-1
|
handover
|
int
|
ber_lock_symbol() of the tracking flag, or -1. |
-1
|
Returns:
| Type | Description |
|---|---|
int
|
First symbol of the measurement window. |
ber_lock_symbol
¶
First symbol from which a verify-counted lock flag is SUSTAINED, or
-1 for 'never locked'. Sustained means sustain consecutive symbols
high AND at least min_frac of everything after that point high too:
the run rejects a single lucky decision, the fraction rejects a
detector that declares early then flaps. Dating the lock by the FINAL
contiguous run instead is right with no noise and badly wrong with it
-- one late dip once moved a reported lock from 415 to 2286 and left no
measurement window at all. The -1 is deliberate: it forces the caller
to say 'never locked' rather than quietly measure a transient.
"Sustained" is sustain consecutive symbols high AND at least min_frac of everything after that point high too. Both halves carry weight: the run rejects a single lucky decision, the fraction rejects a detector that declares early then flaps. Dating the lock by the FINAL contiguous run instead is right with no noise and badly wrong with it — one late dip once moved a reported lock from 415 to 2286 and left no measurement window.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
flags
|
NDArray[uint8]
|
Input. |
required |
sustain
|
int
|
Input. |
200
|
min_frac
|
float
|
Input. |
0.9
|
Returns:
| Type | Description |
|---|---|
int
|
The symbol index, or -1 for "never locked" — the honest answer, which forces the caller to say so rather than measure a transient. |
Related pages¶
Gallery — Measuring an Error Rate, Defensibly, Gallery