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 describes what instruments are on the bench,
how they connect, and what safety limits 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, wraps it in a SCPI codec, and hands that to the driver. 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)
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 instance owns its own 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: 8 # expose only the wired inputs (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
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).
Rule of thumb: SCPI over USB/LAN/serial → Shape 1. Anything with its own protocol/framing → 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.
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 the code lives in one
shared registry, referenced by type: — it is 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
that the Pi's Ethernet port has no address on — so it's simply unreachable. The
ethernet: block makes the rack bring itself onto those subnets automatically at
boot: it ensures a route for declared TCPIP devices, passively sniffs the wire
to hear instruments announce themselves, 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 are 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