All docsStellar LinkRF bench · by Stellar Systems v0.1.0

Control & Automation

Scenarios

The satlink.scenario/v1 DSL: file structure, how a run executes, timing, every timeline action (set, ramp, curve, traffic, inject and expect packets, radio, reg, tone), topology and chain conditioning.

A scenario is a YAML file describing a timed sequence of actions on the bench (traffic, channel impairments, radio settings, register writes, tones, packets) and a set of assertions that judge the run from what the board actually measured. The daemon runs it and stores a report.

Assertions and reports have their own page: Assertions and Reports.

A complete example#

The reference health check, dsl/scenarios/healthcheck/hc_qpsk.yaml, without its comments:

YAML
version: "satlink.scenario/v1"

metadata:
  name: "hc_qpsk"
  display_name: "healthcheck: QPSK, uncoded, PL loopback — the reference arm"
  author: "F. du Cray"
  tags: ["healthcheck", "coverage"]

profile_ref: "hc_qpsk"

chain:
  target: "pl"

timeline:
  - id: "feed"
    at: "3s"
    traffic:
      action: "start"
      packet: { protocol: "csp", src: 1, dst: 10, dport: 7, sport: 7 }
      payload_bytes: 200
  - id: "feed_off"
    at: "25s"
    traffic:
      action: "stop"

assertions:
  - name: "frames_were_delimited_during_the_feed"
    type: "metric_threshold"
    metric: "reg.rx_demod.deframer.frame_cnt.delta"
    operator: ">="
    value: 200
    during: { step: "feed", settle: "4s" }

  - name: "the_receiver_was_not_starved"
    type: "metric_threshold"
    metric: "rx.agc_gain.max"
    operator: "<="
    value: 15.0
    during: { step: "feed", settle: "4s" }

  - name: "the_link_stayed_locked"
    type: "metric_threshold"
    metric: "rx.lock.mean"
    operator: ">="
    value: 0.9
    during: { step: "feed", settle: "4s" }

pass_criteria:
  mode: "all"

File structure#

KeyRequiredMeaning
versionyesMust start with satlink.scenario/ (satlink.scenario/v1); other files are skipped
metadata.nameyesThe identifier used to run it. Scenarios are keyed by this name, not by file name
metadata.display_name, description, author, tagsnoShown in listings and the web console
profile_refnoThe profile to apply at the start of the run: a bare name (hc_qpsk) or a path (profiles/pl_loopback.yaml, reduced to pl_loopback). Also accepted as metadata.profile_ref
chainnoChain conditioning before the run (see below)
topologynoScenario-scoped link endpoints (see below)
channel.initialnoThe channel emulator's state at the start of the run (see Channel Models)
timelinenoThe actions, each with its own time
assertionsnoHow the run is judged. A run with no assertions does not pass
pass_criteria.modenoall (default): every assertion must pass and be verified. any: at least one
artifactsnosave_packet_trace, save_metrics_csv, save_event_log, save_iq_capture: accepted but descriptive. Every report already carries its events, assertions and telemetry samples

Where scenarios live#

The daemon loads every *.yaml and *.yml file under [daemon] scenarios_dir, recursively (/opt/satlink/dsl/scenarios on the board, organised in bringup/, healthcheck/, impairments/ and nominal/). A file that fails to load is skipped with a warning in the log.

A file is refused at load when:

  • any duration (at, from, to, within, curve point t, assertion within) cannot be parsed — it would otherwise become a zero-width window that passes;
  • chain.target is not one of air, pl, device-loopback, none.

Durations#

SuffixUnitExamples
msmilliseconds (integer)500ms
s, sec, secsseconds3s, 2.5s
m, min, minsminutes2m, 1.5min
h, hour, hourshours1h

All times are relative to the start of the run's timeline.

Managing and running scenarios#

Shell
satlinkctl scenario list
satlinkctl scenario get hc_qpsk
satlinkctl scenario run hc_qpsk            # prints the run id
satlinkctl runs --limit 10
RoutePurpose
GET /api/v1/scenariosList loaded scenarios
GET /api/v1/scenarios/{name}One scenario
POST /api/v1/scenarios/validateCheck a scenario (including its durations) without saving it
POST /api/v1/scenariosCreate or replace one: {"yaml": "<the file>", "overwrite": true}
POST /api/v1/scenarios/{name}/runStart a run; 202 with the run id
GET /api/v1/scenarios/activeThe run in progress
POST /api/v1/scenarios/active/cancelCancel it
GET /api/v1/scenario-runs, /scenario-runs/{run_id}, /scenario-runs/{run_id}/eventsRun history

POST /api/v1/scenarios validates first, writes <name>.yaml into scenarios_dir, and makes the scenario runnable at once. On the board that directory is on the RAM disk: an uploaded scenario is lost at the next reboot. Upload it again after a reboot or a redeploy, or add it to dsl/scenarios/ and build a release. The web console's scenario editor uses the same route.

How a run executes#

  1. Refused if busy. Only one scenario runs at a time; a second launch gets 409 naming the run in progress. It is never queued.
  2. Chain conditioning. The chain is brought to the scenario's target before anything else, and before a run record exists. If the target cannot be reached the launch is refused with the unmet condition, and no report is written. Conditioning that needs an AD9361 tuning takes tens of seconds, so the request can take that long; run satlinkctl chain up beforehand to make it instant.
  3. t = 0. The run clock starts. A scenario_start event is logged.
  4. Profile apply. profile_ref is applied. Its registers land a few seconds into the run (up to about 9 s has been observed), so do not schedule measurements in the first seconds. The apply resets the channel emulator to pass-through.
  5. Receiver clear. On the air path (loopback mux off), the receiver's loops are flushed 500 ms after the first traffic feed starts. On the PL loopback nothing is done.
  6. Initial channel state. channel.initial is written and committed. If the commit fails, a channel_staged event says the run starts from the profile's pass-through state instead.
  7. Timeline. Instant steps fire at their at time; spans run concurrently over their window.
  8. Clean-up. Any traffic feed is stopped, any tone is switched off and the DAC handed back, and scenario endpoints are released, including when the run is cancelled.
  9. Judgement. Assertions are evaluated against the events and the telemetry samples collected during the run, and the report is stored.

A cancelled run is recorded as cancelled: its assertions were written for a whole timeline and neither verdict applies.

The timeline#

Each entry has an optional id, a trigger, and exactly one action.

TriggerFormUsed by
Instantat: "12s"set, traffic, inject_packet, expect_packet, radio, reg
Spanfrom: "10s", to: "22s"ramp, curve, tone

The id names the step for assertions (during: {step: …}, event_ref) and appears in the event log.

Spans run concurrently. A long ramp, a curve over the same window and instant steps inside it all keep their own schedule. The channel is locked per sweep point, not per step.

A ramp or curve is swept one point every 500 ms, capped at 120 points (so a window longer than 60 s gets coarser steps). Each point is written and committed. If a span starts late (for example because the profile apply was slow) its report line says so, and the points whose time has passed fire immediately.

set — instant channel change#

YAML
- id: "link_down"
  at: "30s"
  set:
    channel.link_state: "down"      # "up" or "down"
- at: "38s"
  set:
    channel.freq_offset_hz: 200
    channel.snr_db: 20

Keys: channel.link_state, channel.snr_db, channel.doppler_hz, channel.freq_offset_hz, channel.path_loss_db, channel.burst_error_rate. Other keys are accepted and reach no register. The change is committed; a channel_set event confirms it, a channel_staged event means the commit timed out and the change did not reach the link (see Channel Models).

ramp — linear sweep#

YAML
- id: "path_loss_ramp"
  from: "4s"
  to: "14s"
  ramp:
    channel.path_loss_db: { from: 0, to: 20 }

curve — piecewise-linear sweep#

YAML
- id: "doppler_residual"
  from: "15s"
  to: "562s"
  curve:
    channel.doppler_hz:
      points:
        - { t: "15s",  value: -10 }
        - { t: "289s", value: -686 }
        - { t: "562s", value: -10 }

Point times t are absolute run times. Between points the value is interpolated linearly; before the first point it holds the first value, after the last it holds the last.

traffic — a sustained feed generated by the daemon#

YAML
- id: "feed_on"
  at: "2s"
  traffic:
    action: "start"
    packet: { protocol: "csp", src: 1, dst: 10, dport: 7, sport: 7 }
    payload_bytes: 200
    interval_ms: 20        # optional
    count: 5000            # optional
- id: "feed_off"
  at: "52s"
  traffic:
    action: "stop"
FieldMeaning
actionstart or stop
packetWhat to repeat: protocol (csp, ccsds, raw), src, dst, dport, sport, optional payload_hex. Absent: raw
payload_bytesSize of the generated pseudo-random payload (default 2048, 1 to 65 536). Ignored when payload_hex is given. CSP payloads are limited to 256 bytes; a larger one is refused when the step fires
interval_msPause between packets. Absent: as fast as the datapath accepts (the DMA write blocks when the buffer is full, so the feed is paced by the modem)
countStop after this many packets. Fixes the offered load, which makes two runs comparable

The feed writes to the datapath directly. It is stopped at the end of every run, cancelled runs included. A feed that cannot start (unknown protocol, no datapath, a packet that does not encode) logs traffic_refused rather than silently measuring silence.

Dense traffic and channel commits pull in opposite directions: the profile sequencer only commits while the datapath is quiet. With interval_ms absent, some channel changes may be staged and not applied; an interval_ms of a few tens of milliseconds leaves gaps for commits.

inject_packet — one packet#

YAML
- id: "tc"
  at: "3s"
  inject_packet:
    direction: "to_rf"
    packet:
      protocol: "csp"          # csp | ccsds | raw
      src: 10
      dst: 1
      dport: 7
      sport: 31
      payload_hex: "5a1e7c0de1255aa0"

The packet is built by the same code as the API's (a CSP packet with normal priority and no flags; a CCSDS telecommand whose APID is dst), queued on the datapath in stream framing and flushed. interface is optional: when it names a scenario endpoint, a copy is published there for observers (see below); otherwise it is only a label. It never changes where the packet goes.

expect_packet — wait for a packet to come back#

YAML
- id: "tm"
  at: "3s"
  expect_packet:
    direction: "from_rf"
    within: "4s"
    packet_match:
      payload_contains_hex: "5a1e7c0de1255aa0"

The step waits up to within for a matching packet in the receive stream. packet_match fields are all optional (an empty match accepts anything): protocol, src, dst, payload_contains_hex. A criterion that cannot be evaluated (a CSP address on data too short to carry a header) does not match.

radio — the AD9361's own settings#

YAML
- id: "tx_level"
  at: "1s"
  radio:
    tx_gain_db: -10          # attenuation, 0 to -89.75
- id: "gain_40"
  at: "30s"
  radio: { rx_gain_db: 40 }
- at: "40s"
  radio: { rx_port: "B_BALANCED" }
FieldMeaning
rx_gain_dbAD9361 RX gain, dB
tx_gain_dbAD9361 TX attenuation, dB (≤ 0)
rx_portAD9361 RX input: A_BALANCED, B_BALANCED, C_BALANCED, A_N, A_P, …

The part's gain is read back into every telemetry sample (part_rx_gain_db), so a gain sweep carries its own independent variable. See dsl/scenarios/bringup/rf_rx_gain_response.yaml.

reg — PL register writes#

YAML
- id: "scrambler_on"
  at: "2s"
  reg:
    writes:
      - { name: "tx.scrambler.ctrl", value: 0xFF01 }
      - { name: "rx_demod.deframer.scramble", value: 0xFF01 }
- id: "clear"
  at: "24s"
  reg:
    writes: [{ name: "rx_demod.carrier_recovery.ctrl", value: 0x03, verify: false }]
FieldMeaning
nameA catalogued register name (see PL Register Map). Unknown and read-only names are refused; raw addresses are not accepted
value32-bit value; YAML accepts 0x…
verifyDefault true: read back and fail the step if it differs. Set false for self-clearing pulse bits and for shadow-bank registers (which read back the active value until a commit); say which in a comment

Writes run in list order. Registers you want to follow over the run must be listed in the daemon's [telemetry] watch_registers (see Telemetry and Metrics).

tone — a pure line from the AD9361's DDS#

YAML
- id: "tone_at_0_25"
  from: "27s"
  to: "47s"
  tone:
    frequency_hz: 100000     # offset from the LO
    scale: 0.25              # fraction of DAC full scale, (0, 1]
    sideband: "upper"        # or "lower"

A tone is always a span: it has to end, and the engine ends it even on cancellation. While it is on, the DAC takes the internal DDS instead of the modem, so the modem's samples are discarded; a tone and a traffic feed cannot overlap. At the end the DAC is handed back to the modem before the tone scales are zeroed.

  • The tone exists only on RF paths. It is refused while the PL loopback mux is on, and refused when the mux cannot be read.
  • scale is not a level in dBm. Measure the correspondence on an analyser once; it depends on the TX attenuation and your cabling. A scale of 0 is refused (use the end of the span).
  • A lower-sideband tone landing on the same side as an upper one reveals an inverted I/Q order.

See dsl/scenarios/bringup/tone_sweep_reference.yaml.

Topology and scenario endpoints#

YAML
topology:
  role: "ground_station_emulator"     # required when topology is present; descriptive
  rf_port: "RF1"                      # descriptive
  interfaces:
    ground_segment:
      kind: "zmq"                     # the only kind implemented
      tx_port: 6001                   # PULL: frames pushed in by an observer
      rx_port: 6002                   # PUB: copies of frames

interfaces opens ZeroMQ endpoints on the link service for the length of the run, separate from the permanent ones, and releases them when the run ends however it ends. A step naming one of them (interface: "ground_segment") publishes a copy of its packet there; the packet still goes to the modem unchanged. A failure to publish the copy is appended to the step's event, never substituted for the transmission result. Naming an interface that was not declared is an error.

bus_bindings (for example downlink_packets: eth0) is decorative, kept so old files still load: it routes nothing and selects nothing.

Chain conditioning before a run#

YAML
chain:
  target: "air"          # air | pl | device-loopback | none
  condition: true        # default
SettingEffect
No chain blockCondition for a target derived from the profile: radio.loopback: true → pl, otherwise air
target: "…"Condition for that target
target: "none" or condition: falseDo not condition

The run is refused when the target cannot be reached; the refusal names the unmet condition and its fix. When it runs, the conditioning is recorded in the report as an event at elapsed 0 (target, steps run, and every condition as observed), and so is an opt-out.

Opt out only when the unconditioned chain is the subject of the test, and say why in a comment. dsl/scenarios/bringup/tx_egress_probe.yaml is the example: it exercises the closed-egress branch, which conditioning would open.

Shipped scenarios#

FolderContent
healthcheck/One scenario per health-check profile (hc_qpsk and its arms), each differing from the reference in one thing; used by the release_healthcheck campaign
impairments/channel_operations_sweep_v1 (the five channel operations with windowed assertions), leo_doppler_pass_v1 (a full LEO pass), low_snr_reacquisition_v1
nominal/Early examples of CSP exchanges. They predate the current DSL (they use bus_bindings, model-only rf.* assertions and keys the engine ignores); read them as history, not as templates
bringup/Bench investigations: gain and port sweeps, tone references, carrier-loop probes, isolated telecommands, register experiments

Writing a scenario that means something#

  1. Start traffic with a traffic step, and leave a few seconds after it before measuring.
  2. Give every assertion a window (during:) and a settle time: lock detectors take up to about 3 s to release after a signal loss.
  3. Put a positive control first: assert the link is up before you impair it.
  4. Assert on measured metrics (rx.*, chan.*, reg.*), never on rf.*.
  5. Watch the first run's event log: a channel_staged, traffic_refused or tone_refused event means a step did not do what the file says.
  6. Make it fail once. Run it against a condition that should turn it red (an inert channel, no traffic) before believing a green result.

Stellar Link · v0.1.0

↑↓ to moveEnter to open