Configuring your rig (rig_config.yml)
The rack reads one YAML file at startup (default rig_config.yml, or the path in
the RIG_CONFIG_PATH env var). It declares the instruments on the bench, how
they connect, and the safety limits that back them up. Everything lives under a
single top-level rig: key.
rig:
id: "rig1"
name: "Production Test Rack"
telemetry: { measurement_interval: 0.1, enabled: true }
devices: [ ... ]
safety: { abort_limits: [ ... ] }
ethernet: { enabled: true }
drivers: [ ... ]
Top-level sections#
| Key | Purpose | Default |
|---|---|---|
id |
Required. Unique rig identifier. Every telemetry/command broker topic is namespaced by it, so it must match the id the agent addresses the rig by. Overridden by the GUPPI_RIG_ID env var once a rig is paired. |
— |
name, description |
Cosmetic labels shown in the UI. | — |
telemetry |
measurement_interval (seconds between samples) and enabled. |
interval 0.1 |
devices |
The instruments on the bench (see below). | [] |
safety |
L1 watchdog abort-limits (see below). | none |
ethernet |
Bring the rack onto networked instruments' subnets (see below). | enabled |
auto_discover_instruments |
Run the startup VISA/LAN scan and merge in undeclared instruments. | true |
drivers |
Filesystem paths to custom driver code (see below). | [] |
The old
instruments:key is deprecated — usedevices:. The loader raises if it seesinstruments:.
Devices#
Each entry describes one instrument. There are two shapes, chosen by whether the
entry has a connection: block.
Shape 1 — rack-managed transport (standard SCPI instruments)#
The rack opens the connection and hands the driver a thread-safe SCPI codec. Use this for any bench instrument you talk to over VISA (USB/LAN/serial).
- id: psu1 # required, unique (ids "system"/"execution"/"artifact" are reserved)
name: "BK9130 Power Supply" # required
type: BK9130 # required — a registered driver name
enabled: true # default true; false skips the device at load
num_channels: 2 # driver-specific (multi-channel PSUs/loads)
channels: [1, 3] # optional: expose only these channels (default: all); count or list
poll_channels: [1] # optional: of the exposed channels, stream only these (default: all)
rate_hz: 10 # optional per-device sample rate (else telemetry.measurement_interval)
role: device # semantic tag: device (equipment) | dut (thing under test)
connection:
type: VISA # VISA | SIM | <registered transport>
address: "USB0::0xFFFF::0x9130::...::INSTR"
timeout: 10.0 # seconds
# read_termination / write_termination: forwarded to the backend, needed for
# raw ::SOCKET instruments that carry no framing of their own.
Shape 2 — driver-owned transport (CAN/I²C/SPI/HTTP boards, DUTs)#
No connection: block. The driver owns the link — it opens,
holds, and closes the connection itself inside connect(). The rack just
constructs the driver with the leftover keys (matched to the driver's __init__
parameters by name) and calls connect().
- id: pb1
name: "Solar-plane PowerBoard"
type: PowerBoard
role: dut
discover: true # signals arrive from a runtime manifest (hot-plug/async)
can_device: /dev/serial/by-id/usb-…-if00 # driver kwargs: PowerBoard(can_device=..., bitrate=..., node_id=...)
bitrate: 1000000
node_id: 20
Use the /dev/serial/by-id/… form for the adapter path — it is stable across
reboots and USB ports, unlike /dev/ttyUSB0. guppi rack devices scan prints
the attached hardware with these stable paths, ready to paste into the config.
A vendor-library device is the same shape — an MCC USB-1608G multifunction DAQ
(pip install 'guppi-rack[daq]' for the uldaq bindings):
- id: daq1
name: "Bench DAQ"
type: MccUsb1608G
role: device # rack equipment, not the thing under test
# unique_id: "01F5A9C2" # serial, to pin one box when the rack has several
differential: false # false -> 16 single-ended AI; true -> 8 differential
ai_range: 10 # AI full scale in volts: 10, 5, 2 or 1
ai_channels: [0, 1, 5, 8, 12] # count (first N) OR an explicit channel list (default: all)
scan_rate: 1000 # hardware-paced samples/channel/s; 0 -> software-paced
dio_outputs: [0, 1] # these DIO bits drive out; the rest read in
ai_channels takes either a count — 8 records the first 8 inputs
(ai0…ai7) — or an explicit list — [0, 1, 5, 8, 12] records exactly
those physical channels, and the signals keep their channel numbers (ai5,
ai8), so the name tracks your wiring. With scan_rate set the box can only
sweep a contiguous channel range in one hardware scan: a sparse list scans
the whole span min..max and discards the unselected channels, so the
aggregate-rate ceiling counts that span, not just the chosen channels. Wire the
channels you sample close together.
With scan_rate set this device is a buffered source: it runs a continuous
hardware-paced scan and hands the telemetry drain loop every sample stamped from
the scan clock, so it captures at its own rate rather than the rack's poll rate.
Analog outputs are discovered at connect (2 on a USB-1608GX-2AO, none otherwise).
SCPI over USB/LAN/serial → Shape 1. Anything with its own protocol or framing (CAN, I²C, HTTP) → Shape 2.
Any device key the loader doesn't recognize (port, can_device, bitrate,
channel_limits, …) is passed straight to the driver, so driver-specific config
flows through without the loader needing to know about each driver.
Selecting channels. Every channelised instrument (PSUs, electronic loads, and any driver built on the shared channel base) accepts two optional keys, applied by the loader regardless of the driver:
channels— which channels this instance exposes.[1, 3]on a 3-output supply drops channel 2 from the whole surface: no2.*signals, no2.set_*capabilities, no telemetry. An integer is the count form (2→ channels 1, 2). Default: every channel the device declares.poll_channels— of the exposed channels, which to stream. Commands and setpoint writes stay available on every exposed channel; this only trims the per-tick telemetry read, which speeds up a frame with many outputs but few under test. Default: poll every exposed channel.
Both take a count or an explicit list, validated in guppi rack config check.
channel_limits stays indexed by physical channel, so selecting a subset doesn't
change how you write it.
Custom driver code — drivers:#
drivers: is not about connections. It's a list of filesystem paths where
custom driver classes are loaded from (a .py file or a package directory,
resolved relative to the config file). A device's type: is looked up by name in
the resulting registry.
You only need this for out-of-tree drivers. In-tree drivers (BK9130, Chroma,
Keysight RP5900, ITECH IT-M3900C, SimPSU, …) are always available and need no
entry. One driver class serves many device instances, so drivers live in a
shared registry referenced by type:, not nested under a device.
drivers:
- "../../../solar-airplane-fsw/tools/dut-driver" # a DUT driver shipped with the firmware repo
See drivers.md to write one.
Networked instruments — ethernet:#
A LAN/LXI instrument often ships with a fixed static IP on an arbitrary subnet
the bench computer has no address on — unreachable until someone adds one. The
ethernet: block makes the rack join those subnets itself at boot: it ensures a
route for declared TCPIP devices, passively sniffs the wire for instrument
announcements, and adds a matching add-only IP alias per subnet. It never runs
DHCP, NAT, or a gateway, and every alias is torn down on shutdown.
ethernet:
enabled: true # default true
iface: eth1 # optional — pin a dedicated port; unset = auto-pick (never the uplink)
discovery_seconds: 5.0 # how long to passively sniff
Omit the block entirely if your rig is all USB — it defaults on but finds nothing
to do. It's gated behind auto_discover_instruments, so turning discovery off
turns this off too.
Safety abort-limits — safety:#
The L1 safety watchdog is an always-on, local, deterministic backstop. Each
armed limit is a comparison value <op> threshold against a live telemetry value.
On a sustained breach the watchdog, in order:
- de-energizes every output (the guarantee),
- aborts the running test (best-effort),
- emits
system.safety_trippedand latches until cleared.
It backstops slow, sustained faults (thermal, sustained over-limit); its response is bounded by the sample cadence. Fast faults belong to the instrument's own OCP/current-limit (L0), not this loop.
safety:
abort_limits:
- path: psu1.1.current # <device>.<channel>.<signal> — a live telemetry path
op: ">=" # one of > >= < <=
threshold: 30 # SIGNED (see below)
min_duration: 0.2 # optional — seconds the breach must persist (default 0 = instant)
label: "psu1 ch1 over-current" # optional — for logs/UI
id: "psu1-oc" # optional — auto-derived from path+op+threshold if omitted
Limits are armed at boot. A test can also arm its own scoped limits at runtime; those are additive.
Bidirectional supplies: one limit per direction#
Regenerative/bidirectional supplies — Keysight RP5900 and ITECH IT-M3900C — report current and power signed by quadrant: positive while sourcing, negative while sinking. The comparison is signed, which is exactly what lets you limit the two directions independently — and you should, because a supply's source and sink ratings are different numbers (it may safely source 80 A but only sink 40 A). A single "magnitude" limit can't express that; two directional limits can:
safety:
abort_limits:
# Source over-current: trips at +80 A
- { path: psu3.1.current, op: ">=", threshold: 80, label: "psu3 ch1 source OC" }
# Sink over-current: trips at −40 A
- { path: psu3.1.current, op: "<=", threshold: -40, label: "psu3 ch1 sink OC" }
# Same idea for power (source/sink watt ratings differ):
- { path: psu3.1.power, op: ">=", threshold: 4000, min_duration: 0.2, label: "psu3 ch1 source OP" }
- { path: psu3.1.power, op: "<=", threshold: -2000, min_duration: 0.2, label: "psu3 ch1 sink OP" }
A unipolar signal — a source-only PSU's current, a voltage, a temperature —
just uses the one direction that matters (op: ">=" for an upper ceiling,
op: "<=" for a lower floor).
Why not a single
abs/magnitude limit? Because the sink and source ratings differ, one magnitude threshold would either trip too early in the higher-rated direction or too late in the lower-rated one. Two signed limits give each direction its own correct threshold, and still let you arm only one direction if that's all that's dangerous.
Validate before you run#
guppi rack config check # validates the config (ids, driver types, YAML) without connecting