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]
The fourth metric needs no truth and still sees a false lock¶
BerMeter scores symbols against truth. FrameMeter scores frames, and it
is the one metric in the set that survives on a real capture and catches the
failure an M-PSK receiver is most prone to.
The other three fail differently, which is why all four are reported together:
| metric | needs truth | sees a stable false lock |
|---|---|---|
EVM (ber_evm_db) |
no | no |
M2M4 (snr.snr_m2m4_db) |
no | no |
BER / SER (BerMeter) |
yes | yes |
FER (FrameMeter) |
no | yes |
Under a stable false lock the constellation is stationary, so a self-referenced EVM and a blind M2M4 both read clean. A CRC-checked frame does not: it either checks or it does not, and no payload truth is consulted to find out.
Two counting rules carry the meaning, and each fails silently if reversed. A
frame whose sync was never detected is an error — score only the frames you
managed to find and the FER improves as the receiver gets worse at finding
them. A frame carrying no CRC is not an error when its sync was found, or
the number measures the frame format rather than the receiver; pass
wfm_frame_crc_ok()'s -1 straight through and that is handled. The two
failure modes stay separately countable through fer() and sync_miss(),
because "the sync word is too short at this Es/N0" and "the demodulator is
making bit errors" are different repairs.
FrameMeter stops on an ERROR target for the same reason BerMeter does, and
that is not cosmetic: it hands back ber_confidence's exact interval, which is
the inverse-binomial one. Using it under a fixed-frame-count rule would be the
wrong sampling model.
>>> from doppler.ber import FrameMeter
>>> met = FrameMeter(target_errors=4)
>>> for i in range(20):
... met.add(sync_ok=1, crc=0 if i % 5 == 0 else 1)
>>> met.frames, met.crc_passed, met.errors
(20, 16, 4)
>>> fer = met.fer()
>>> round(fer.p_hat, 3), fer.lo < fer.p_hat < fer.hi
(0.158, True)
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
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If construction fails. The exception message is |
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 |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
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. |
...
|
FrameMeter
¶
Create an accumulator.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target_errors
|
int
|
frame errors to accumulate before |
200
|
conf
|
float
|
confidence level in (0, 1); 0 is taken as BER_CONF. |
0.99
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If construction fails. The exception message is |
Examples:
Create with defaults:
enough
property
¶
True once target_errors frame errors have accumulated -- the
stopping condition, so a caller loops records until the measurement has
the precision it asked for rather than until a frame count someone
guessed.
reset
¶
Clear every counter; the configuration is untouched.
The target and the confidence level are what the caller asked for, so resetting the accumulation must not silently re-negotiate them. Use it between records, or to discard a run that turned out to be measuring the wrong thing.
Examples:
add
¶
Record one frame's outcome. sync_ok is the DETECTOR's own decision
(ber_align_t.ok, or burst_demod's frame offset validity) -- never a
threshold applied afterwards to a statistic. crc is
wfm_frame_crc_ok()'s return passed straight through: 1 pass, 0 fail, -1
the frame carries no CRC. A frame is an error when its sync was not
detected, or when it was and the CRC failed; with no CRC carried, a
detected frame counts as delivered, because counting it as an error
would measure the frame format rather than the receiver.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sync_ok
|
int
|
non-zero when the frame's sync word was detected. Pass the
detector's own decision — |
required |
crc
|
int
|
|
required |
Examples:
>>> from doppler.ber import FrameMeter
>>> met = FrameMeter(target_errors=10)
>>> met.add(1, 1) # found, and it checked
>>> met.add(1, 0) # found, and the CRC failed
>>> met.add(0, 0) # never found: still a frame you did not deliver
>>> met.add(1, -1) # found, no CRC: delivered but not CHECKED
>>> met.frames, met.sync_detected, met.crc_passed, met.errors
(4, 3, 1, 2)
fer
¶
Frame error rate with its exact 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. A frame that was never
detected counts as an error -- a frame you did not detect is a frame
you did not deliver.
ber_confidence(errors, frames, conf) — the same interval ber_meter
reports, which is generic over trials and therefore applies to frames
unchanged. Assert on lo, never on p_hat.
Returns:
| Type | Description |
|---|---|
BerInterval
|
the rate with its exact interval. |
Examples:
sync_miss
¶
Sync MISS rate with its exact interval, as a BerInterval. Reported as a miss rather than a detection rate so it is an ERROR rate like every other number in this module and the same interval applies unchanged. This is what turns 'is this sync word long enough at this Es/N0' into a measurement rather than a judgement.
Reported as a miss rate rather than a detection rate so it is an ERROR
rate like every other number here, and so the same interval applies
without reinterpretation. This is what turns "is this sync word long
enough at this Es/N0" into a measurement — ber_align_detect()
already returns margin_db and runner_db per attempt, and
accumulating the decisions is what answers the question with a number.
Returns:
| Type | Description |
|---|---|
BerInterval
|
the miss rate with its exact interval. |
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 FrameMeter 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 FrameMeter 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 FrameMeter 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 FrameMeter be used in a with statement so its C resources are
released deterministically on exit rather than at collection time.
Returns:
| Type | Description |
|---|---|
FrameMeter
|
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 FrameMeter.
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. 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
¶
Where a steady-state measurement may start: max(budget, timing lock, carrier lock). The analytic budget and the receiver's own indicators are both fallible in the SAME direction, so whichever settles last decides. There was a fourth term until doppler#877: a receiver that handed the carrier from an NDA discriminator to a decision-directed one settled last of all, contributing its instant PLUS the budget again (measured on 8PSK: handover at symbol 2525 against a 2000-symbol budget, and 5.95x the coherent bound if the window started at 2000 rather than 4525). No receiver in this library hands over any more, so the term went with the handover rather than remaining as an argument that could only be passed -1. 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.
The POLICY for where a steady-state window may start, in one place:
max(budget, timing lock, carrier lock). The analytic budget and the
receiver's own indicators are both fallible in the SAME direction, so
whichever settles last decides.
There was a fourth term until doppler#877. A receiver that handed the
carrier from an NDA discriminator to a decision-directed one settled
last of all — the handover fired after every lock indicator and the new
loop then had its own transient, so it contributed its instant + the
budget again (measured on 8PSK at its SER=1e-3 anchor: handover at
symbol 2525 against a 2000-symbol budget, and 5.95x the coherent bound
if the window started at 2000 rather than 4525). No receiver in this
library hands over any more, so the term went with the handover rather
than remaining as an argument that could only be passed -1.
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.
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
|
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, M-PSK Receiver — Performance Characterisation Design — Detection Sizing — the four laws behind one prefix, The FEC Receive Half, Receiver Test Harness Contributing — Adding an algorithm — the lifecycle, Measuring a receiver