Note
Go to the end to download the full example code.
Looking at the signals along a chain#
Three ways to see what a block does to the signal. comnum.Chain.trace() runs one
trial and keeps the frame after every block, for plots and NumPy; probe blocks
measure metrics at a point of the link during a whole campaign; the dump block
saves the samples at a point of the link for the first trials of every condition, as
SigMF recordings that any tool reads. A Python block can also keep whatever it wants.
The chain is the coherent receiver of specs/coherent_link.yaml.
Trace. The frame after every block of one trial: its samples, samples per
symbol, and what the source recorded (bits, symbols, constellation). Trace.plot
draws one panel per stage with a function of comnum.plot; the functions take a
frame directly and read its sampling rate and constellation.
frames = link.trace(condition, comnum.Rng(1))
for name, f in frames.items():
print(f"{name:>16}: {f.samples.size:>6} samples at {f.sps:g} per symbol")
fig = frames.plot("constellation", stages=["cdc", "foe", "bps"], skip=2560, max_points=4000)
fig.set_size_inches(10, 3.4)
fig.tight_layout()

source: 16384 samples at 1 per symbol
pulse_shape: 32768 samples at 2 per symbol
tx_laser: 32768 samples at 2 per symbol
fiber: 32768 samples at 2 per symbol
normalize: 32768 samples at 2 per symbol
awgn: 32768 samples at 2 per symbol
lo_laser: 32768 samples at 2 per symbol
cfo: 32768 samples at 2 per symbol
cdc: 32768 samples at 2 per symbol
matched_filter: 32768 samples at 2 per symbol
gardner: 32750 samples at 2 per symbol
decimate: 16375 samples at 1 per symbol
foe: 16375 samples at 1 per symbol
bps: 16375 samples at 1 per symbol
frame_sync: 16384 samples at 1 per symbol
The spectrum before and after dispersion compensation, and the eye after the matched filter.
fig, axes = plt.subplots(1, 3, figsize=(11, 3.4))
comnum.plot.psd(frames["awgn"], ax=axes[0])
axes[0].set_title("on the channel")
comnum.plot.psd(frames["cdc"], ax=axes[1])
axes[1].set_title("after dispersion compensation")
comnum.plot.eye(frames["matched_filter"], ax=axes[2], traces=150, skip=4096)
axes[2].set_title("after the matched filter")
fig.tight_layout()

Probes. During a campaign, a probe after a block measures metrics there, on
every trial, reported as <block>.<metric> with their intervals. The power and
the PAPR are measured anywhere; the metrics on decisions (SNR, EVM, error rates)
need the samples aligned on the transmitted symbols, here after frame
synchronisation.
probed = link.probes(
{"awgn": ["power_dbm", "papr_db"], "cdc": ["papr_db"], "frame_sync": ["snr_db", "evm"]},
skip_start=2560,
skip_end=256,
)
res = probed.run(
grid={"length_km": [1000], "snr_db": [14, 18]}, metrics=["ber"], stop={"max_trials": 8}
)
for r in res:
m = r["metrics"]
print(
f"SNR {r['condition']['snr_db']} dB: {m['awgn.power_dbm']['value']:.1f} dBm on the channel, "
f"PAPR {m['awgn.papr_db']['value']:.1f} dB on the channel and {m['cdc.papr_db']['value']:.1f} dB "
f"after dispersion compensation; {m['frame_sync.snr_db']['value']:.1f} dB and "
f"EVM {100 * np.sqrt(m['frame_sync.evm']['value']):.1f} % at the decisions"
)
SNR 14 dB: 30.3 dBm on the channel, PAPR 10.5 dB on the channel and 7.4 dB after dispersion compensation; 13.8 dB and EVM 20.5 % at the decisions
SNR 18 dB: 30.1 dBm on the channel, PAPR 10.3 dB on the channel and 6.9 dB after dispersion compensation; 17.3 dB and EVM 13.6 % at the decisions
Dumps. A dump block saves the samples at its position for the first trials
of every condition (trials of them), as SigMF recordings
<dir>/<campaign>/<condition>/<label>_<trial>.sigmf-* with the samples per symbol
and the symbol rate in their metadata; the campaign goes on as usual on every core.
comnum.read_sigmf() reads them back, as does any SigMF tool.
folder = Path(tempfile.mkdtemp())
tapped = link.insert_after("cdc", tap=B.Dump(dir=str(folder), trials=1))
tapped.run(
grid={"length_km": [1000], "snr_db": [14, 18]},
metrics=["ber"],
stop={"max_trials": 4},
name="taps",
)
for p in sorted(folder.rglob("*.sigmf-meta")):
print(p.relative_to(folder))
x, fs, meta = comnum.read_sigmf(folder / "taps" / "length_km=1000_snr_db=18" / "tap_0.sigmf-meta")
print(
x.shape,
f"{fs / 1e9:.0f} GHz,",
{k: v for k, v in meta["global"].items() if k.startswith("comnum:")},
)
taps/length_km=1000_snr_db=14/tap_0.sigmf-meta
taps/length_km=1000_snr_db=18/tap_0.sigmf-meta
(32768,) 64 GHz, {'comnum:block': 'tap', 'comnum:trial': 0, 'comnum:stream': 0, 'comnum:sps': 2.0, 'comnum:symbol_rate': 32000000000.0}
The first trial of a condition draws from Rng.stream(seed, name, i, 0), so the
recording is exactly the frame that trace gives with that generator: a dump of
the parallel runner and a trace in Python are the same signal.
i = 1 # the condition snr_db = 18 is the second of the grid
same = tapped.trace({"length_km": 1000, "snr_db": 18}, comnum.Rng.stream(7, "taps", i, 0))
print("dump and trace agree:", np.array_equal(same["cdc"].samples, x))
dump and trace agree: True
A Python block as a tap. On the Python runner, a block is any callable: one that
appends a copy of the samples to a list keeps every trial of the campaign in memory,
which a dump would write to disk.
kept = []
def keep(frame, rng):
kept.append(frame.samples.copy())
link.insert_after("bps", keep=keep).run(
grid={"length_km": [1000], "snr_db": [18]}, metrics=["ber"], stop={"max_trials": 8}
)
print(len(kept), "trials kept,", kept[0].shape)
8 trials kept, (16375,)
Saving a trace. Trace.save writes the samples of every stage to a .npz
file, under stage/<name>/ for every block, for NumPy or any tool that reads them.
['stage/awgn/sps', 'stage/awgn/stream0', 'stage/bps/sps', 'stage/bps/stream0', 'stage/cdc/sps', 'stage/cdc/stream0'] …
Total running time of the script: (0 minutes 0.733 seconds)