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:
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#
| Key | Required | Meaning |
|---|---|---|
version | yes | Must start with satlink.scenario/ (satlink.scenario/v1); other files are skipped |
metadata.name | yes | The identifier used to run it. Scenarios are keyed by this name, not by file name |
metadata.display_name, description, author, tags | no | Shown in listings and the web console |
profile_ref | no | The 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 |
chain | no | Chain conditioning before the run (see below) |
topology | no | Scenario-scoped link endpoints (see below) |
channel.initial | no | The channel emulator's state at the start of the run (see Channel Models) |
timeline | no | The actions, each with its own time |
assertions | no | How the run is judged. A run with no assertions does not pass |
pass_criteria.mode | no | all (default): every assertion must pass and be verified. any: at least one |
artifacts | no | save_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 pointt, assertionwithin) cannot be parsed — it would otherwise become a zero-width window that passes; chain.targetis not one ofair,pl,device-loopback,none.
Durations#
| Suffix | Unit | Examples |
|---|---|---|
ms | milliseconds (integer) | 500ms |
s, sec, secs | seconds | 3s, 2.5s |
m, min, mins | minutes | 2m, 1.5min |
h, hour, hours | hours | 1h |
All times are relative to the start of the run's timeline.
Managing and running scenarios#
satlinkctl scenario list
satlinkctl scenario get hc_qpsk
satlinkctl scenario run hc_qpsk # prints the run id
satlinkctl runs --limit 10| Route | Purpose |
|---|---|
GET /api/v1/scenarios | List loaded scenarios |
GET /api/v1/scenarios/{name} | One scenario |
POST /api/v1/scenarios/validate | Check a scenario (including its durations) without saving it |
POST /api/v1/scenarios | Create or replace one: {"yaml": "<the file>", "overwrite": true} |
POST /api/v1/scenarios/{name}/run | Start a run; 202 with the run id |
GET /api/v1/scenarios/active | The run in progress |
POST /api/v1/scenarios/active/cancel | Cancel it |
GET /api/v1/scenario-runs, /scenario-runs/{run_id}, /scenario-runs/{run_id}/events | Run 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#
- Refused if busy. Only one scenario runs at a time; a second launch gets 409 naming the run in progress. It is never queued.
- 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 upbeforehand to make it instant. - t = 0. The run clock starts. A
scenario_startevent is logged. - Profile apply.
profile_refis 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. - 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.
- Initial channel state.
channel.initialis written and committed. If the commit fails, achannel_stagedevent says the run starts from the profile's pass-through state instead. - Timeline. Instant steps fire at their
attime; spans run concurrently over their window. - 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.
- 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.
| Trigger | Form | Used by |
|---|---|---|
| Instant | at: "12s" | set, traffic, inject_packet, expect_packet, radio, reg |
| Span | from: "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#
- id: "link_down"
at: "30s"
set:
channel.link_state: "down" # "up" or "down"
- at: "38s"
set:
channel.freq_offset_hz: 200
channel.snr_db: 20Keys: 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#
- id: "path_loss_ramp"
from: "4s"
to: "14s"
ramp:
channel.path_loss_db: { from: 0, to: 20 }curve — piecewise-linear sweep#
- 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#
- 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"| Field | Meaning |
|---|---|
action | start or stop |
packet | What to repeat: protocol (csp, ccsds, raw), src, dst, dport, sport, optional payload_hex. Absent: raw |
payload_bytes | Size 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_ms | Pause 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) |
count | Stop 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#
- 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#
- 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#
- 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" }| Field | Meaning |
|---|---|
rx_gain_db | AD9361 RX gain, dB |
tx_gain_db | AD9361 TX attenuation, dB (≤ 0) |
rx_port | AD9361 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#
- 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 }]| Field | Meaning |
|---|---|
name | A catalogued register name (see PL Register Map). Unknown and read-only names are refused; raw addresses are not accepted |
value | 32-bit value; YAML accepts 0x… |
verify | Default 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#
- 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.
scaleis 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#
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 framesinterfaces 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#
chain:
target: "air" # air | pl | device-loopback | none
condition: true # default| Setting | Effect |
|---|---|
No chain block | Condition for a target derived from the profile: radio.loopback: true → pl, otherwise air |
target: "…" | Condition for that target |
target: "none" or condition: false | Do 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#
| Folder | Content |
|---|---|
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#
- Start traffic with a
trafficstep, and leave a few seconds after it before measuring. - Give every assertion a window (
during:) and asettletime: lock detectors take up to about 3 s to release after a signal loss. - Put a positive control first: assert the link is up before you impair it.
- Assert on measured metrics (
rx.*,chan.*,reg.*), never onrf.*. - Watch the first run's event log: a
channel_staged,traffic_refusedortone_refusedevent means a step did not do what the file says. - Make it fail once. Run it against a condition that should turn it red (an inert channel, no traffic) before believing a green result.