Chains of blocks#
A Chain describes a link as an ordered sequence of named blocks,
in the spirit of torch.nn.Sequential: the same object runs once to inspect the
signals, runs a whole campaign on the parallel Rust runner, and converts to and from
a declarative specification.
Building a chain#
The blocks of the declarative links are classes of comnum.blocks, one per entry
of comnum.spec_blocks() in CamelCase (awgn_ebn0 → AwgnEbn0), their
parameters as keyword arguments (an unknown name raises TypeError; help(B.Bps)
shows them). A parameter given as comnum.var() takes the value of a grid
variable, as $name does in a specification:
import comnum
from comnum import blocks as B
link = comnum.Chain(
tx=B.Source(constellation="16-QAM", n_symbols=8192, symbol_rate=32e9),
shaping=B.PulseShape(sps=2, rolloff=0.2),
channel=B.Awgn(snr_db=comnum.var("snr_db")),
laser=B.PhaseNoise(linewidth_hz=100e3),
mf=B.MatchedFilter(rolloff=0.2),
down=B.Decimate(),
cpr=B.Bps(n=32, b=32),
)
A chain behaves as an ordered mapping: link["cpr"], link.names, len(link);
link.replace(cpr=B.Ddpll(...)) and link.insert_after("channel", cfo=B.Cfo(offset_hz=1e6))
return modified copies, so variants of a link stay one line apart. A chain is itself a
block: comnum.Chain(tx=transmitter, channel=..., rx=receiver) nests sub-chains,
whose blocks are named rx.cpr and so on.
Running once and tracing#
link(condition, rng) runs the chain once and returns the final
Frame: the samples, the samples per symbol, and what the metrics need
(the transmitted bits, indices and symbols, the constellation). link.trace(condition,
rng) returns a copy of the frame after every block — the quickest way to look at a
constellation after each stage of the receiver:
frames = link.trace({"snr_db": 18}, comnum.Rng(1))
frames["channel"].samples # before the receiver
frames["cpr"].samples # after carrier recovery
Python blocks#
Any callable f(frame, rng) is a block, and so is any subclass of
Block. A Python block reads and writes frame.samples (and
frame.sps), and frame.record(name, value) reports a value, estimated as a mean
by the runners:
class Clipper(comnum.Block):
def __init__(self, level):
self.level = level
def __call__(self, frame, rng):
x = frame.samples
frame.samples = x * np.minimum(1, self.level / np.maximum(np.abs(x), 1e-12))
clipped = link.insert_after("shaping", clip=Clipper(1.2))
Campaigns#
Chain.run evaluates the chain on a grid, with the metrics
and the stopping rule of the specifications:
results = link.run(
grid={"snr_db": [14, 16, 18, 20]},
metrics={"report": ["ber", "snr_db"], "skip_start": 512},
stop={"min_errors": {"ber": 200}, "max_trials": 500},
seed=1,
)
results[0]["metrics"]["ber"] # {value, lo, hi, kind, count, total}
Both kinds of chain run on the Rust runner: a chain of native blocks on all cores
(comnum.run_spec()), a chain with a Python block on one thread, its Python blocks
called under the GIL. Both draw trial t of condition i from
Rng.stream(seed, name, i, t) and share the stopping rules, the statistics and the
reports: inserting a Python block that leaves the frame unchanged gives the same error
counts, trial for trial, so a Python prototype of a block can be checked against the
native chain before it is ported to Rust.
Specifications#
Chain.to_spec returns the declarative specification of a
native chain (the block names become labels, the variables $name), ready to be
saved as YAML and run by the comnum command (comnum run spec.yaml); Chain.from_spec
reads one back, from a file, a YAML text or a dict, keeping its grid, metrics and
stopping rule as settings, so that
link = comnum.Chain.from_spec("specs/coherent_link.yaml")
results = link.replace(bps=B.Bps(n=64, b=32)).run()
reruns the campaign of the file with a single block changed.