Skip to content

A Frame You Built, Generated

wfmgen frames a waveform two ways, and they are not two representations. --acq-code, --sync and --crc spell one frame, the common [preamble × reps | sync | payload | crc]. Everything else — a field of your own at a position you choose, a coding stage, a CCSDS CADU — is a wfm_frame_desc_t: fields in wire order, stages with the span each covers.

wfm_source_t.frame is the way in. Point a source at a description and that description is the frame. wfmgen --frame FILE and a scene's "frame" key reach the same member.

The whole thing, in one program

A 16-bit header of the caller's own bits, a payload, and a CRC-16 over a span named rather than counted — then the samples, checked back against the description that produced them.

#include <complex.h>
#include <stdio.h>
#include <string.h>
#include <doppler/wfm/wfm_compose.h>
#include <doppler/wfm/wfm_frame.h>
#include <doppler/wfm_synth/wfm_synth_core.h>

#define NBITS 56u /* 16 header + 24 payload + 16 CRC */
#define SPS 4u
#define NS (NBITS * SPS)

int
main (void)
{
  /* Bits the caller owns. A description BORROWS them, so they must outlive
     every compose call that reads it. */
  static uint8_t hdr[16], payload[24];
  for (unsigned i = 0; i < 16u; i++)
    hdr[i] = (uint8_t)((0x5C5Cu >> (15u - i)) & 1u);
  for (unsigned i = 0; i < 24u; i++)
    payload[i] = (uint8_t)((i * 7u + 1u) & 1u);

  wfm_seq_t h = { 0 }, p = { 0 };
  h.kind = WFM_SEQ_LITERAL, h.bits = hdr, h.len = 16u;
  p.kind = WFM_SEQ_LITERAL, p.bits = payload, p.len = 24u;

  /* Fields in WIRE order; the stage names the span it covers. The cover
     reaches the derived field, which is what wires that field's producer --
     so a CRC's position and the fact that a CRC produces it are one
     declaration rather than two that can disagree. */
  wfm_frame_desc_t d;
  memset (&d, 0, sizeof d);
  if (dp_wfm_frame_add_field (&d, "hdr", &h, 0u) < 0
      || dp_wfm_frame_add_field (&d, "payload", &p, 0u) < 0
      || dp_wfm_frame_add_derived (&d, "crc", WFM_FRAME_CRC_BITS) < 0
      || dp_wfm_frame_add_stage (&d, WFM_STAGE_CRC16, "payload", "crc") < 0)
    return 1;

  /* One source, carrying it. The description is the whole frame -- fixed
     bits here, sent once. A payload that changes frame to frame is a
     data:LEN field instead, filled from the source's `data`. */
  wfm_source_t src = { 0 };
  src.type = WFM_SYNTH_BITS;
  src.modulation = 1; /* bpsk */
  src.sps = (int)SPS;
  src.snr = WFM_SYNTH_SNR_CLEAN; /* no AWGN, so the checks are equalities */
  src.snr_mode = 1;
  src.frame = &d;
  if (dp_wfm_source_error (&src) != NULL)
    return 1;

  wfm_segment_t seg = { 0 };
  seg.sources = &src;
  seg.n_sources = 1u;
  seg.fs = 1.0e6; /* no num_samples: the frame sets the run, NS samples */

  dp_wfm_compose_state_t *c = dp_wfm_compose_create (&seg, 1u, 0, 0);
  if (!c)
    return 1;
  float complex out[NS];
  size_t n = 0, got_n;
  while (n < NS && (got_n = dp_wfm_compose_execute (c, out + n, NS - n)) > 0)
    n += got_n;
  dp_wfm_compose_destroy (c);

  /* The samples carry the DESCRIPTION's bits. `dp_wfm_frame_assemble` builds
     them independently; dp_bpsk_map's convention is 0 -> +1, 1 -> -1, so with a
     clean rectangular source at zero offset the sign is the bit. */
  uint8_t want[NBITS], got[NBITS];
  if (dp_wfm_frame_assemble (&d, NULL, want, NBITS) != NBITS)
    return 1;
  for (unsigned i = 0; i < NBITS; i++)
    got[i] = crealf (out[i * SPS]) < 0.0f ? 1u : 0u;

  printf ("%zu samples, %u frame bits, match: %s\n", n, NBITS,
          memcmp (got, want, NBITS) == 0 ? "yes" : "NO");
  return memcmp (got, want, NBITS) == 0 ? 0 : 1;
}
224 samples, 56 frame bits, match: yes

What the description bought

Three things, none of which a flag offers:

A field at a position you choose. The flat fields offer a preamble, a sync word and a payload, in that order. hdr above is neither of the first two and sits ahead of the payload because the description says so.

A cover that is named, not counted. dp_wfm_frame_add_stage(&d, WFM_STAGE_CRC16, "payload", "crc") says what three integers used to. The header is deliberately outside the CRC's cover — a receiver has to find the header before it can check anything — and that choice is one argument, not an offset arithmetic exercise.

A kind that is open. wfm_stage_kind_t stops at WFM_STAGE_USER = 0x1000; above that the kinds are yours, and the kernel comes in through wfm_frame_ops_t. A transform doppler has never heard of is a stage, not a pull request against a header — and the table extends the built-ins, so supplying one kind does not mean restating the CRC.

That is the claim this page rested most weight on and demonstrated least, so §6 of the example below now runs it: a whitener at WFM_STAGE_USER + 1, its kernel supplied through a one-entry table. Without the kernel the assembly is refused — never a silent skip, because a stage that quietly did not run produces a frame that still assembles, still decodes against itself, and syncs to nothing. With it, the frame assembles and reverses through the same open lookup, and dp_wfm_frame_check reports both stages reversed.

The common frame is a description too

--acq-code, --sync and --crc go through one function, dp_wfm_frame_fixed(), which builds the common frame as an ordinary description with fields called "preamble", "sync", "payload" and "crc". Hand that description to a second source and the two compose byte-identically — which is what makes "one frame representation" a measurement rather than a claim.

The worked version of that check, and of every claim above, is native/examples/wfmgen_frame_demo.c: it composes framed against unframed, demodulates the frame back to the description's own bits, shows one description making a three-frame record from three chunks of data -- its payload a data:LEN field, each frame the next chunk with its own CRC -- proves the equivalence above, and ends on the caller's own stage kind. It self-validates and exits non-zero if any of it stops holding — which is why the sections are numbered here rather than counted.

make build
./build/native/examples/wfmgen_frame_demo

From Python, where there is no ops table by design, the same end is reached by running the transform in the caller and handing the wire the result — src/doppler/examples/frame_own_stage_demo.py, which describes the same generic frame, whitens it with its own kernel and checks the recovered bits against the same description. The trade is the one #1125 chose: once per frame in Python, rather than per stage inside the assembler.

Reaching it from the other interfaces

The C struct is the primary interface, and the other two carry the same description rather than reimplementing it — see Waveforms: in a scene, and from Python for the scene JSON's frame key and what Python does with it.

  • A CCSDS CADU — the description on its own: what a real standard's fields and covers look like, and why the marker is covered by the inner code and by neither the outer code nor the randomiser.
  • Name Your Own Code — an outer and an inner code that are nobody's standard, run end to end.
  • DsssBurstReceiver — a spread frame from a description, transmitted on all three wfmgen faces and checked against the same description on receive.