The sample word

Sample packing

A converter and a fabric run at rates that are nowhere near each other. An RFSoC ADC samples at one to five giga-samples per second; the logic behind it runs at 250 to 500 MHz. That is an order of magnitude, and it is not going to close.

So the converter cannot hand over one sample per fabric cycle — there aren’t enough cycles. It hands over several samples in one beat, and how many is not a design preference:

samp_per_word  =  samp_rate / f_axis

A samp_rate = 1 GSa/s into a f_axis = 250 MHz fabric, samp_per_word=4. At 2 GSa/s, samp_per_word=8. Packing is a consequence of the clock ratio, and every parameter on this page exists to say precisely how those samples are arranged once you have accepted that there must be more than one.

This page describes how the samples are packed into words. The packing convention used in Waveflow attempts to match the AMD packing so the logic you develop can directly interface to AMD RFDC IP with minimal changes. In general, you will not need to write the packing and unpacking routines yourself – we provide methods and other Waveflow blocks that perform all the packing for you. For completeness, this page provides the details if you wish to inspect the actual sample words in and out of the AXI interfaces.

Describing packing formats with RfdcSampWord

In Waveflow, the packing format used by the converter is described by a type, not a handful of parameters. The base type is RfdcSampWord. An example creation of a type is as follows:

from waveflow.hw.rfdc_samp_word import RfdcSampWord

Word = RfdcSampWord.specialize(samp_per_word=4, bits_per_samp=14, bits_per_samp_pack=16)
Word.bitwidth        # 64  = 4 × 16
Word.samp_type()     # FixedField, 14 bits — the QUANTIZER

The general set of parameters are:

parameter what it fixes default
samp_per_word samples one beat carries — complex ones when iq_mode 1
bits_per_samp effective bits: what the converter resolves, and the quantizer’s precision 16
bits_per_samp_pack container bits: the slot one sample occupies on the bus 16
iq_mode real samples, or interleaved I/Q False
justify where the effective bits sit inside the slot — "left" or "right" "left"
iq_order which of I and Q takes the lower slot "i_low"

Important notes:

  • samp_per_word is always an integer since samples cannot straddle a beat. Rfdc refuses a configuration where it does not, rather than rounding.
  • Any parameters you omit are inherited from the class you called specialize on — which is what makes a board preset an ordinary subclass rather than a factory.
  • justify’s default is an assumption awaiting a lab measurement, not a measurement.

Everything else is derived — read it, never restate it:

  is for
bitwidth samp_per_word × bits_per_samp_pack, doubled for I/Q the AXI-Stream width
samp_type() FixedField at bits_per_samp, rounding and saturating quantizing a sample
slot_type() signed IntField at bits_per_samp_pack what the serializers see
slots_per_word() samp_per_word, doubled for I/Q slots in one beat
justify_shift() bits_per_samp_pack - bits_per_samp when left-justified, else 0 the one rule below

These are the converter’s entire sample geometry: Rfdc reads them off the type and declares none of them itself.

Rfdc takes the word type and reads its geometry off it. You never restate the width; there is one place it can be wrong.

It is a type rather than three loose numbers for a reason worth stating: these rules are AMD’s, not Waveflow’s. A different converter family packs differently, and naming the vendor makes that coupling visible instead of implying the layout is universal.

Board presets

Rather than restate a board’s geometry at every call site, subclass it once. Rfsoc4x2SampWord is the preset for this project’s board — the RFSoC 4x2 (Zynq UltraScale+ ZU48DR), whose converters resolve 14 bits and carry each sample in a 16-bit slot:

from waveflow.hw.rfdc_samp_word import Rfsoc4x2SampWord

Word = Rfsoc4x2SampWord.specialize(samp_per_word=4)
Word.describe()   # '4 real sample(s)/beat, 14-in-16 (left-justified) -> 64-bit word'

You ask only for the beat geometry your design needs and the board’s two numbers come along. That works because a preset is an ordinary subclass and specialize inherits anything you do not pass — Word is still an Rfsoc4x2SampWord, and specializing it again keeps the 14-in-16.

Another part packs differently and gets its own preset, which is a two-line subclass:

from typing import ClassVar
from waveflow.hw.rfdc_samp_word import RfdcSampWord

class MyBoardSampWord(RfdcSampWord):
    """A part that resolves 12 bits into a 16-bit slot."""
    bits_per_samp:      ClassVar[int] = 12
    bits_per_samp_pack: ClassVar[int] = 16

Arrays of words

To represent a block of samples, use an ordinary DataArray over the word type. In Waveflow, this construction is permitted since RfdcSampWord subclasses IntField rather than inventing a container. For example, to create an array of nwords=64 sample words, each with samp_per_word=4 samples per word, we can write:

from waveflow.hw.dataschema import DataArray

n_words = 64
Word = RfdcSampWord.specialize(
    samp_per_word=4, 
    bits_per_samp=14, 
    bits_per_samp_pack=16)
Block = DataArray.specialize(
  element_type=Word, 
  max_shape=(n_words,))
blk = Block()
blk.val            # ndarray, dtype uint64, shape (64,)  — 64 beats = 256 samples

blk.val is a numpy array, not a list of field objects: a DataArray over a numpy-backed element is an ndarray. Index it, slice it, and hand it to numpy directly.

In the example above, the Word.bitwidth=64 which is mapped in Waveflow’s convention to uint64. A word wider than 64 bits is stored as (n, k) little-endian uint64 rows rather than refused — the same wide-word convention the rest of Waveflow uses.

Multi-channel arrays

If you want to describe a multi-channel data (e.g., for multiple TX or RX antennas), use a 2D array:

n_words = 64
n_tx = 2  # number of TX antennas
Word = RfdcSampWord.specialize(
    samp_per_word=4,
    bits_per_samp=14,
    bits_per_samp_pack=16)
Block = DataArray.specialize(
  element_type=Word,
  max_shape=(n_tx, n_words))
blk = Block()
blk.val       # ndarray, dtype uint64, shape (2, 64)

The shape is channel-major(n_ch, n_words), matching the (n_ch, blksize) blocks the RF side already carries, so there is no transpose at the boundary. Row ch is what port ch carries: the converter presents one AXI-Stream port per channel, each row is packed independently, and each goes out on its own port.

(pack below is stricter than DataArray here: it takes exactly 2-D, so one channel is (1, n_samp) rather than (n_samp,).)

Converting samples to words and back

Two functions, and they are exact inverses:

from waveflow.hw.rfdc_samp_word import Rfsoc4x2SampWord, pack, unpack

Word  = Rfsoc4x2SampWord.specialize(samp_per_word=4)
words = pack(Word, stored)      # (n_ch, n_samp) integers -> (n_ch, n_words) uint64
stored = unpack(Word, words)    # and back, exactly

You should not need anything else on this page to move samples across the converter’s fabric side. Everything below it is the why — the conventions these two implement — and it is there for the reader checking the model against PG269, not for the one packing a block.

It takes integers, and that is the interesting part

pack takes stored integers — what quantization produced — and refuses a float array. Two questions, two calls, and they are different questions:

stored = from_real(x, Word.samp_type())   # quantize — the CONVERTER's question, at bits_per_samp
words  = pack(Word, stored)               # lay out  — the BUS's question, and lossless

A real-valued input would make pack lossy: quantization happening inside a call whose name says formatting. That is the one place it must not hide, and it is the effective-vs-container confusion in another hat — so the refusal is the feature, and the error message names the call you are missing.

Turning words back into amplitudes is the same split run backwards:

x = to_real(array(Word.samp_type(), unpack(Word, words)))

The caller therefore knows the amplitude scale. That is right: full_scale is a property of the converter, not of the word.

What it refuses

   
n_samp not a multiple of samp_per_word refused, never padded — the same choice Rfdc makes about a non-integer rate
a float sample array refused; quantize first (above)
a sample outside bits_per_samp refused — an over-range value shifts into its neighbour’s slot and corrupts it silently
complex samples into a real word, or the reverse refused; iq_mode is a property of the word

The first refusal is what buys the second function its signature: because n_samp is always a whole number of words, n_samp = n_words × samp_per_word on the way back, and unpack needs no length argument.

I/Q and wide words

When iq_mode is set, pack takes a complex array of integer-valued samples and routes through the slot order below; unpack returns complex. When Word.bitwidth exceeds 64 the word arrays gain the trailing axis of the (n, k) wide-word convention — (n_ch, n_words, k) — rather than the word being refused.

Slot order: oldest sample, lowest bits

Samples are packed time-ascending from the LSBs — the oldest sample in the least significant slot, each in a fixed bits_per_samp_pack slot. At 8-bit slots, the samples [0, 64, -64, -128] pack to 0x80c04000.

Do not hand-roll this. Packing goes through the generated array serializers, never a .range() you wrote — and the reason is sharper than tidiness:

Slot order is unobservable at samp_per_word == 1. With one sample per beat there is nothing to order, so a slot-order bug passes every test you thought to run — and then fails at four.

Effective bits and container bits are two numbers

The bits a converter resolves and the bits its slot occupies are not the same thing:

  what it is ZU48DR
bits_per_samp effective — what the converter actually resolves, and the quantizer’s precision 14
bits_per_samp_pack container — the width of the slot on the bus 16

They coincide on a part whose resolution happens to match its slot width, and that coincidence is exactly what makes conflating them dangerous.

Why it matters, concretely. bits_per_samp sets the quantizer. Take the container width instead — which is what the bus arithmetic tells you — and you get an ap_fixed<16,1> quantizer on a 14-bit converter: a quantisation step four times finer than the hardware’s, understating the one effect this model exists to reproduce bit-exactly. A design tuned against that model would be tuned against a converter that does not exist.

justify — declared, and not yet confirmed

If 14 effective bits sit in a 16-bit slot, where in the slot?

   
"left" MSB-aligned — the effective bits occupy the high end, low bits zero
"right" LSB-aligned — sign-extended into the high bits

The default is "left" and it is UNCONFIRMED. Which one AMD’s RFDC uses is a PG269 question nobody on this project has answered. It is on the board bring-up list beside the TVALID question and will be settled in the lab.

"left" is the default because MSB alignment makes full scale the same integer whatever the converter’s resolution, so PL logic need not be re-scaled per part. That is a reason to expect it — not evidence that it is so.

Both alignments are implemented on both sides, so a lab answer is a one-field change.

One consequence that catches people: under MSB alignment the low 16 − 14 = 2 bits of a slot are not the converter’s. A test ramp stepping by 1 does not survive quantisation, which is why the RF examples step by 4 — and why that step, changing, would witness a change in justify.

While bits_per_samp == bits_per_samp_pack, justify is a no-op.

Real and I/Q

samp_per_word counts samples; iq_mode says what a sample is:

iq_mode a sample is one beat carries width
False a real value samp_per_word real samples samp_per_word × bits_per_samp_pack
True a complex (I, Q) pair samp_per_word complex samples samp_per_word × bits_per_samp_pack × 2

A complex sample occupies two slots, so the same count needs twice the bus. An I/Q design fits the same width by halving samp_per_word; the information density is identical either way, and the parameter counts what the design thinks in.

iq_mode lives on the word, not on the converter, because it is a statement about packing — it is what makes bitwidth follow from the type rather than from a flag elsewhere.

Ask for one the same way:

word = Rfsoc4x2SampWord.specialize(samp_per_word=2, iq_mode=True)   # still a 64-bit word

The converter’s RF blocks then carry complex128, its beats carry interleaved I/Q, and the port count does not move. Which side of the converter the I/Q mapping happens on — and the one combination that is refused — is real and I/Q.

iq_order

Which of I and Q takes the lower slot. Like slot order it is invisible at samp_per_word == 1, so it is pinned by a test at two samples per word.

The default "i_low" is UNCONFIRMED, and it is the field most likely to be wrong — above justify on the board bring-up list. Both the Python model and the C++ twin read the value rather than assuming one, so a lab answer is a one-field change.

Next

Source of truth: waveflow/hw/rfdc_samp_word.py, tests/hw/test_rfdc_samp_word.py.