Analysing a campaign#
A campaign answers a question with numbers; the analysis afterwards looks at the
numbers (Results), compares variants (compare()), measures
inside the link (probes) and looks at the signals (Trace and
comnum.plot).
Results#
Chain.run returns Results: the list of one
dict per condition of comnum.run_spec(), with the specification of the
campaign attached.
res = link.run(grid={"snr_db": range(0, 21, 2), "m": [4, 16, 64]},
metrics=["ber", "snr_db"],
stop={"min_errors": {"ber": 200}, "max_trials": 1000})
res.variables, res.metrics # ['snr_db', 'm'], ['ber', 'snr_db']
x, ber, lo, hi = res.series("snr_db", "ber", interval=True, m=16)
res.where(m=64) # the conditions of 64-QAM
res.to_frame() # a pandas DataFrame, one row per condition
res.plot("snr_db", "ber", by="m") # one curve per order, with the intervals
res.save("results/qam.json") # the numbers, the specification, the version
res = comnum.Results.load("results/qam.json")
Results.plot takes a closed form, theory=lambda x, m: …, drawn as a dotted line
on every curve; min_count leaves out the points of too few errors.
Variants#
comnum.compare() runs several chains with the same grid, metrics and stopping rule
and gathers them, the name of every chain as the variable variant:
res = comnum.compare(
{"CDC": link, "DBP 1 step": link.replace(comp=B.ManakovDbp(steps_per_span=1)),
"DBP 4 steps": link.replace(comp=B.ManakovDbp(steps_per_span=4))},
grid={"power_dbm": range(-4, 9, 2)}, metrics=["snr_db"], stop={"max_trials": 4},
)
res.plot("power_dbm", "snr_db", by="variant")
Probes#
A probe measures metrics at a point of the link during the whole campaign, on the
parallel runner: Chain.probe inserts a probe block after
a named block, and its metrics are reported as <block>.<metric>.
probed = bicm.probes({"rx": ["snr_db", "ser"], "tx": ["power_dbm"]})
res = probed.run(grid={"ebn0_db": [6, 8, 10]}, metrics=["coded_ber"])
res.series("ebn0_db", "rx.ser") # the symbol errors before decoding
The metrics of the specifications (snr_db, ber, ser, evm, gmi…)
need the samples aligned on the transmitted symbols at one sample per symbol: after the
detection of a wireless receiver, after frame synchronisation of an optical one.
power_dbm and papr_db are measured anywhere.
Signals#
Chain.trace runs one trial and keeps the frame after every
block: a Trace, which plots every stage and saves them to a .npz
file (stage/<name>/stream0, stage/<name>/sps…). The functions of
comnum.plot draw a frame or an array — constellation(),
eye(), time(), psd() — and take its
samples per symbol, sampling rate, constellation and polarisations from the frame;
comnum.plot.style() sets the figure style of this documentation:
frames = link.trace({"snr_db": 18}, comnum.Rng(1))
frames.plot("constellation", stages=["decimate", "foe", "bps"], skip=512)
comnum.plot.eye(frames["matched_filter"])
comnum.plot.psd(frames["wdm"])
frames.save("results/trace.npz")
A dump block saves the samples at its position during a campaign, for the first
trials trials of every condition, as SigMF recordings
<dir>/<campaign>/<condition>/<label>_<trial>.sigmf-* (one per stream, with the
samples per symbol and the symbol rate in the metadata), which comnum.read_sigmf()
and any SigMF tool read back; the first trial of a condition is the frame that
trace gives with Rng.stream(seed, name, i, 0):
tapped = link.insert_after("cdc", tap=B.Dump(dir="results/signals", trials=2))
tapped.run(grid={"snr_db": [14, 18]}, name="taps")
x, fs, meta = comnum.read_sigmf("results/signals/taps/snr_db=18/tap_0.sigmf-meta")
Profiling#
profile=True (profile: true in a specification, --profile on the command
line) times every block of every trial and reports the mean time per trial as the
metrics <block>.time_ms, with link.time_ms for the whole link.
Results.profile tabulates them in the order of the
link with the share of every block, and Chain.profile
runs a short campaign for that purpose; both runners time their blocks the same way,
so a Python block and its native version compare directly:
for r in link.profile(grid={"snr_db": [18]}, stop={"max_trials": 16}):
print(f"{r['block']:>16} {r['time_ms']:8.3f} ms {100 * r['share']:5.1f} %")
The times are those of one trial on one core; the throughput of the parallel runner
is about the number of cores divided by link.time_ms.
Receiver posteriors#
The information rates of the metrics mi and gmi use a genie: the least-squares
gain and the true noise variance. A real decoder uses the receiver’s own beliefs, its
output \(z\) and the noise variance \(\hat\sigma^2\) it estimated. The air
metrics measure the rate of that decoder, \(\mathrm{AIR} = H(X) - E[-\log_2
q(x|z)]\) with \(q(x|z) \propto e^{-|z - x|^2/\hat\sigma^2}\): the rate under
mismatched decoding, a lower bound on the information rate (Alvarado et al. 2018,
Remarks 4–5).
Metric |
Meaning |
|---|---|
|
symbol-wise and bit-wise rates with the receiver’s posterior |
|
the same with the best scaling of the posterior, per block |
|
\(\hat\sigma^2 / \overline{|z - x|^2}\): 1 when calibrated, below 1 when over-confident |
A receiver block declares its posterior: its output and the \(\hat\sigma^2\) it
believes in. After a classic receiver, the block noise_estimate declares one, from
the decisions or from the known symbols; a receiver added from another crate declares
its own.