# Bench bring-up with instro

Check each instrument’s data as you connect a bench

**Requirements:** a `default` [profile](https://nominal-io.github.io/nominal-client/guides/api-keys-setup/)
for the Nominal Python Client, and the packages the notebook imports, including
[instro](https://instro.nominal.io). [Running the notebooks](https://dev.nominal.io/demos/running.md) explains how to set them
up.

## The bench

Bench 7 is a new test bench for a power-conversion board, the device under test (DUT). The DUT
converts 12 V to 5 V. Before the bench can test DUTs, you bring it up: you connect each instrument,
and make sure that its data is correct.

- The power supply’s output 1 gives the DUT its 12 V input. Output 2 gives 5 V to the fixture,
  the board that holds the DUT.
- The DMM measures the fixture’s 5 V rail.
- The electronic load draws 1 A from the DUT’s 5 V output, and measures the voltage and current
  there.

Each instrument connects to the bench PC. On the PC, instro reads each instrument and streams its
data to Nominal. Each instrument box shows the instrument’s instro name.

```mermaid
block-beta
  columns 6
  block:shelf:3
    columns 3
    dmm["DMM<br><code>dut_dmm</code>"] psu["Power supply<br><code>bench_psu</code>"] load["Electronic load<br><code>dut_load</code>"]
  end
  space:2
  pc["💻 Bench PC<br>instro"]
  space:6
  block:fixture:3
    columns 3
    rail["Fixture<br>5 V rail"] space dut["DUT"]
  end
  space:2
  nominal[("Nominal")]
  dmm -- "leads" --> rail
  psu -- "5 V" --> rail
  psu -- "12 V" --> dut
  dut -- "5 V, 1 A" --> load
  shelf -- "USB / LAN" --> pc
  pc -- "stream" --> nominal
```

On a real bench, each instrument uses the instro driver for its model, with the instrument’s VISA
address. For example, for a Keysight 34461A DMM on the network:

```python
from instro.drivers import Keysight34461A

dmm_hardware = Keysight34461A("TCPIP::192.168.1.20::INSTR")
```

instro has drivers for power supplies, DMMs, electronic loads, oscilloscopes and other instruments
from many vendors. The [instro documentation](https://instro.nominal.io) lists them. This notebook
uses instro’s virtual drivers, which simulate the instruments, so it needs no hardware. Only the
driver changes on a real bench; the rest of the code stays the same.

The virtual instruments aren’t wired together, so you set what each one measures:

```ipython3
from instro import MeasurementFunction
from instro.drivers import VirtualDMM, VirtualELoad, VirtualPSU

# The DUT draws 0.5 A from 12 V (24 ohm), and the fixture 0.5 A from 5 V (10 ohm).
psu_hardware = VirtualPSU(num_channels=2, load_resistance={1: 24.0, 2: 10.0})
# The DMM's leads are on the wrong output of the power supply, so it measures 12 V.
dmm_hardware = VirtualDMM({MeasurementFunction.DC_VOLTAGE: 12.0})
# The DUT's 5 V output, which drops a little as the load draws current.
eload_hardware = VirtualELoad(source_voltage=5.0, source_resistance=0.05)
```

## Connect the bench to Nominal

In Nominal, an asset represents a physical system: here, bench 7. Every instrument streams to the
asset’s `bench` dataset. A run marks a period of time on the asset: here, the bring-up session. To
watch the data arrive during bring-up, open the run in Nominal with **Open in Nominal** on its card, shown below.

```ipython3
import pandas as pd
from nominal.core import NominalClient, set_display_options

# Show readable timestamps on Nominal's cards.
set_display_options(time_format="readable")

client = NominalClient.from_profile("default")
```

```ipython3
bench = client.get_or_create_asset_by_properties(
    {"bench_id": "bench-7", "example": "bench_bringup"}, name="Bench 7"
)
dataset = bench.get_or_create_dataset("bench", name="Bench 7 instruments")
# end=None leaves the run open until the session ends.
run = bench.create_run(
    "Bench 7 bring-up", start=pd.Timestamp.now(tz="UTC").floor("s"), end=None
)
run
```

```
Run(rid='ri.scout.cerulean-staging.run.38b3cbf7-d469-d6ac-04cc-316a4aa22f39', name='Bench 7 bring-up', description='', properties=mappingproxy({}), labels=(), links=(), start=1793992319000000000, end=None, run_number=473, assets=('ri.scout.cerulean-staging.asset.cb545fdc-997d-d062-bbc0-c84979601495',), created_at=1793992319242619000, is_archived=False)
```

Run **#473 Bench 7 bring-up** [Open in Nominal ↗](https://app.gov.nominal.io/w/ri.security.cerulean-staging.workspace.1870868e-c361-fd28-4bd5-e1b2325eb774/runs/ri.scout.cerulean-staging.run.38b3cbf7-d469-d6ac-04cc-316a4aa22f39)

| RID | `ri.scout.cerulean-staging.run.38b3cbf7-d469-d6ac-04cc-316a4aa22f39`⧉ |
| --- | --- |
| Start | 2026-11-06 19:11:59 UTC |
| End | ongoing |
| Created | 2026-11-06 19:11:59 UTC |

RelationshipsAssets (1)

| [Bench 7](https://app.gov.nominal.io/w/ri.security.cerulean-staging.workspace.1870868e-c361-fd28-4bd5-e1b2325eb774/assets/ri.scout.cerulean-staging.asset.cb545fdc-997d-d062-bbc0-c84979601495)⧉ |
| --- |

Datasets (1)

| Data scope | Dataset | Start | End |
| --- | --- | --- | --- |
| `bench` | [Bench 7 instruments](https://app.gov.nominal.io/w/ri.security.cerulean-staging.workspace.1870868e-c361-fd28-4bd5-e1b2325eb774/datasets/ri.catalog.cerulean-staging.dataset.c47a79c8-590b-64f8-45c1-038b6164a2b9)⧉ | no data yet |  |

## Connect the instruments

An instro instrument wraps a driver. `open()` connects to the
instrument, and `start()` starts a background daemon which reads the
instrument at a fixed interval. It sends each reading to the
instrument’s list of publishers; e.g., a `NominalCorePublisher`
streams the readings to a Nominal dataset. instro names each channel
`<instrument name>.<channel>`. The instrument’s card in the notebook
shows its latest readings.

The setup below includes a few typical mistakes that are easy to make on a new bench.
In the next section, we’ll perform a few checks that will find them.

### The power supply

```ipython3
import time

from instro import InstroDMM, InstroELoad, InstroPSU, LoadMode
from instro.lib import BackgroundDaemonConfig, NominalCorePublisher
from instro.lib import set_display_options as set_instro_display_options

# Show readable timestamps on instro's cards.
set_instro_display_options(time_format="readable")

psu = InstroPSU(
    name="bench_psu",
    driver=psu_hardware,
    num_channels=2,
    # Stream every reading to the bench's dataset.
    publishers=[NominalCorePublisher(dataset=dataset)],
    # Read both outputs every 100 ms.
    background_config=BackgroundDaemonConfig(interval=0.1),
)
psu.open()
# Set each output's voltage and current limit, and turn the output on.
psu.apply(current_limit=1.0, voltage=12.0, enable=True, channel=1)
psu.apply(current_limit=1.0, voltage=5.0, enable=True, channel=2)
psu.start()
# The card shows the rate of each channel over the last 5 s.
time.sleep(5)
psu
```

```
InstroPSU(name='bench_psu', driver=VirtualPSU, publishers=[NominalCorePublisher, DequeInMemoryPublisher], background_running=True)
```

InstroPSU **bench_psu** running

| Driver | `VirtualPSU` |
| --- | --- |
| Channel count | 2 |
| Background | running, every 100 ms |
| Loop time | 100 ms |

Publishers (2)

| Publisher | Sent | Errors | Last error |
| --- | --- | --- | --- |
| Nominal dataset [Bench 7 instruments](https://app.gov.nominal.io/w/ri.security.cerulean-staging.workspace.1870868e-c361-fd28-4bd5-e1b2325eb774/datasets/ri.catalog.cerulean-staging.dataset.c47a79c8-590b-64f8-45c1-038b6164a2b9)⧉ | 355 | 0 |  |
| in-memory buffer (8 channels, last 10 samples each) | 349 | 0 |  |

Channels (14)

| Channel | Kind | Latest | Rate | Count | Time |
| --- | --- | --- | --- | --- | --- |
| `ch1.current` | measurement | 0.499912 | 9.8/s | 50 | 2026-11-06 19:12:04 UTC |
| `ch1.current.cmd` | command | 1 | 0/s | 1 | 2026-11-06 19:11:59 UTC |
| `ch1.enabled` | measurement | 1 | 9.8/s | 50 | 2026-11-06 19:12:04 UTC |
| `ch1.enabled.cmd` | command | 1 | 0/s | 1 | 2026-11-06 19:11:59 UTC |
| `ch1.voltage` | measurement | 12.0001 | 9.8/s | 50 | 2026-11-06 19:12:04 UTC |
| `ch1.voltage.cmd` | command | 12 | 0/s | 1 | 2026-11-06 19:11:59 UTC |
| `ch2.current` | measurement | 0.500338 | 9.8/s | 50 | 2026-11-06 19:12:04 UTC |
| `ch2.current.cmd` | command | 1 | 0/s | 1 | 2026-11-06 19:11:59 UTC |
| `ch2.enabled` | measurement | 1 | 9.8/s | 50 | 2026-11-06 19:12:04 UTC |
| `ch2.enabled.cmd` | command | 1 | 0/s | 1 | 2026-11-06 19:11:59 UTC |
| `ch2.voltage` | measurement | 5.0011 | 9.8/s | 50 | 2026-11-06 19:12:04 UTC |
| `ch2.voltage.cmd` | command | 5 | 0/s | 1 | 2026-11-06 19:11:59 UTC |
| `daemon_work_time` | measurement | 9.2412e-05 | 9.8/s | 49 | 2026-11-06 19:12:04 UTC |
| `loop_time` | measurement | 0.100101 | 9.8/s | 49 | 2026-11-06 19:12:04 UTC |

Default tags (1)

| instro | 1.23.0 |
| --- | --- |

The power supply measures the voltage, the current, and the enable state of each output. The
channels that end in `.cmd` are the commands that the code sent, such as the 12 V setpoint
`ch1.voltage.cmd`. Each instrument also records its daemon’s timing: `loop_time` is the time
between two reads, and `daemon_work_time` is the time that one read takes.

### The DMM

```ipython3
dmm = InstroDMM(
    name="dmm",
    driver=dmm_hardware,
    publishers=[NominalCorePublisher(dataset=dataset)],
    background_config=BackgroundDaemonConfig(interval=0.1),
)
dmm.open()
dmm.set_measurement_function(MeasurementFunction.DC_VOLTAGE)
dmm.start()
time.sleep(5)
dmm
```

```
InstroDMM(name='dmm', driver=VirtualDMM, publishers=[NominalCorePublisher, DequeInMemoryPublisher], background_running=True)
```

InstroDMM **dmm** running

| Driver | `VirtualDMM` |
| --- | --- |
| Background | running, every 100 ms |
| Loop time | 100 ms |

Publishers (2)

| Publisher | Sent | Errors | Last error |
| --- | --- | --- | --- |
| Nominal dataset [Bench 7 instruments](https://app.gov.nominal.io/w/ri.security.cerulean-staging.workspace.1870868e-c361-fd28-4bd5-e1b2325eb774/datasets/ri.catalog.cerulean-staging.dataset.c47a79c8-590b-64f8-45c1-038b6164a2b9)⧉ | 100 | 0 |  |
| in-memory buffer (3 channels, last 10 samples each) | 99 | 0 |  |

Channels (4)

| Channel | Kind | Latest | Rate | Count | Time |
| --- | --- | --- | --- | --- | --- |
| `daemon_work_time` | measurement | 5.1051e-05 | 9.8/s | 49 | 2026-11-06 19:12:09 UTC |
| `dc_voltage` | measurement | 11.9911 | 9.8/s | 50 | 2026-11-06 19:12:09 UTC |
| `loop_time` | measurement | 0.100112 | 9.8/s | 49 | 2026-11-06 19:12:09 UTC |
| `set_measurement_function.cmd` | command | DC_VOLTAGE | 0/s | 1 | 2026-11-06 19:12:04 UTC |

Default tags (1)

| instro | 1.23.0 |
| --- | --- |

The DMM measures one value: the DC voltage.

### The electronic load

```ipython3
eload = InstroELoad(name="dut_load", driver=eload_hardware)
eload.open()
# Sink a constant 1 A.
eload.set_mode(LoadMode.CC)
eload.set_level(1.0)
eload.output_enable(True)
eload.start()
time.sleep(5)
eload
```

```
InstroELoad(name='dut_load', driver=VirtualELoad, publishers=[DequeInMemoryPublisher], background_running=True)
```

InstroELoad **dut_load** running

| Driver | `VirtualELoad` |
| --- | --- |
| Background | running, every 1 s |
| Loop time | 1 s |

Publishers (1)

| Publisher | Sent | Errors | Last error |
| --- | --- | --- | --- |
| in-memory buffer (4 channels, last 10 samples each) | 14 | 0 |  |

Channels (7)

| Channel | Kind | Latest | Rate | Count | Time |
| --- | --- | --- | --- | --- | --- |
| `ch1.current` | measurement | 1.00088 | 0.8/s | 5 | 2026-11-06 19:12:13 UTC |
| `ch1.enabled.cmd` | command | 1 | 0/s | 1 | 2026-11-06 19:12:09 UTC |
| `ch1.level.cmd` | command | 1 | 0/s | 1 | 2026-11-06 19:12:09 UTC |
| `ch1.mode.cmd` | command | CC | 0/s | 1 | 2026-11-06 19:12:09 UTC |
| `ch1.voltage` | measurement | 4.95913 | 0.8/s | 5 | 2026-11-06 19:12:13 UTC |
| `daemon_work_time` | measurement | 4.376e-05 | 0.8/s | 4 | 2026-11-06 19:12:13 UTC |
| `loop_time` | measurement | 1.00009 | 0.8/s | 4 | 2026-11-06 19:12:13 UTC |

Default tags (1)

| instro | 1.23.0 |
| --- | --- |

The electronic load measures the voltage and the current at its input.

## Check the bench’s data

After bring-up, the bench runs a production test on each DUT, by
reading channel data from Nominal. Before the bench runs the test, we
make sure that each channel that the test reads was published to
Nominal, has a value in the expected range, and arrives at the desired
rate.

The test spec is where we define these requirements. The test needs
10 Hz data. The lowest rate that the check accepts is 9.5 Hz, because
the interval between reads varies a little.

```ipython3
SPEC = {
    "channels": {
        "bench_psu.ch1.voltage": (11.8, 12.2),
        "bench_psu.ch1.current": (0.45, 0.55),
        "bench_psu.ch2.voltage": (4.9, 5.1),
        "bench_psu.ch2.current": (0.45, 0.55),
        "dut_dmm.dc_voltage": (4.9, 5.1),
        "dut_load.ch1.voltage": (4.85, 5.0),
        "dut_load.ch1.current": (0.95, 1.05),
    },
    "min_rate_hz": 9.5,
}
```

`check()` is a function, defined below, that we wrote to retrieve the
last 10 s of the bench’s data from Nominal and to compare each channel
with the spec above. It also reports instruments that sends data but
aren’t named in the spec.

```ipython3
from nominal.thirdparty.pandas import datasource_to_dataframe

def check(spec: dict, window_s: float = 10) -> pd.DataFrame:
    """Compare the last ``window_s`` seconds of the bench's data in Nominal with ``spec``."""
    end = pd.Timestamp.now(tz="UTC")
    start = end - pd.Timedelta(seconds=window_s)
    data = datasource_to_dataframe(dataset, start=start, end=end)
    data.index = pd.to_datetime(data.index)

    report = {}
    for channel, (low, high) in spec["channels"].items():
        values = data[channel].dropna() if channel in data else pd.Series()
        if values.empty:
            report[channel] = {"status": "no data"}
            continue
        latest = values.iloc[-1]
        # The rate from the median interval between samples. One sample gives no rate.
        intervals = values.index.diff().dropna()
        rate = 1 / intervals.median().total_seconds() if len(intervals) else 0
        if not low <= latest <= high:
            status = "out of range"
        elif rate < spec["min_rate_hz"]:
            status = "too slow"
        else:
            status = "ok"
        report[channel] = {"latest": latest, "rate (Hz)": rate, "status": status}

    # An instrument that sends data, but that the spec doesn't name.
    named = {channel.split(".")[0] for channel in spec["channels"]}
    sent = data.dropna(axis="columns", how="all")
    sending = {channel.split(".")[0] for channel in sent}
    for instrument in sorted(sending - named):
        report[f"{instrument}.*"] = {"status": "not in spec"}

    report = pd.DataFrame.from_dict(report, orient="index").round(3)
    # Show a value that a row doesn't have as an empty cell.
    return report.astype(object).fillna("")
```

```ipython3
check(SPEC)
```

|  | latest | rate (Hz) | status |
| --- | --- | --- | --- |
| bench_psu.ch1.voltage | 11.975 | 9.982 | ok |
| bench_psu.ch1.current | 0.5 | 9.982 | ok |
| bench_psu.ch2.voltage | 4.996 | 9.983 | ok |
| bench_psu.ch2.current | 0.5 | 9.984 | ok |
| dut_dmm.dc_voltage |  |  | no data |
| dut_load.ch1.voltage |  |  | no data |
| dut_load.ch1.current |  |  | no data |
| dmm.* |  |  | not in spec |

The power supply’s channels pass. The check finds two problems:

- The DMM’s name is `dmm`, so its data arrives as `dmm.dc_voltage`, and `dut_dmm.dc_voltage` has
  no data.
- The electronic load’s channels have no data. Its code gives it no publishers, so its card shows
  only the **in-memory buffer**, which feeds the card. Nothing ended up published to Nominal.

An instrument’s name is fixed when you create it. To rename the DMM, close it and connect it again
as `dut_dmm`. You can add a publisher to a running instrument, so give the electronic load a Nominal
publisher without a restart.

```ipython3
dmm.close()
dmm = InstroDMM(
    name="dut_dmm",
    driver=dmm_hardware,
    publishers=[NominalCorePublisher(dataset=dataset)],
    background_config=BackgroundDaemonConfig(interval=0.1),
)
dmm.open()
dmm.set_measurement_function(MeasurementFunction.DC_VOLTAGE)
dmm.start()

eload.add_publisher(NominalCorePublisher(dataset=dataset))

# Wait until the window holds only data from after the changes.
time.sleep(15)
check(SPEC)
```

|  | latest | rate (Hz) | status |
| --- | --- | --- | --- |
| bench_psu.ch1.voltage | 12.008 | 9.98 | ok |
| bench_psu.ch1.current | 0.499 | 9.98 | ok |
| bench_psu.ch2.voltage | 4.999 | 9.983 | ok |
| bench_psu.ch2.current | 0.5 | 9.986 | ok |
| dut_dmm.dc_voltage | 12.007 | 9.978 | out of range |
| dut_load.ch1.voltage | 4.951 | 1.0 | too slow |
| dut_load.ch1.current | 1.0 | 1.0 | too slow |

The DMM and the electronic load now send their data to Nominal. The check finds two more problems:

- `dut_dmm.dc_voltage` is 12 V, where the spec expects the 5 V rail. The DMM’s leads are on the
  power supply’s 12 V output.
- The electronic load’s channels are too slow. Its code gives no `background_config`, so instro
  reads it at the default interval of 1 s. Its card shows **Background** as every 1 s.

Move the DMM’s leads to the 5 V rail, and make the daemon read the electronic load every 100 ms:

```ipython3
# In the simulation, moving the leads changes the voltage that the virtual DMM reads.
dmm_hardware.stimulus[MeasurementFunction.DC_VOLTAGE] = 5.0
eload.background_interval = 0.1

time.sleep(15)
check(SPEC)
```

|  | latest | rate (Hz) | status |
| --- | --- | --- | --- |
| bench_psu.ch1.voltage | 11.979 | 9.982 | ok |
| bench_psu.ch1.current | 0.5 | 9.982 | ok |
| bench_psu.ch2.voltage | 5.004 | 9.981 | ok |
| bench_psu.ch2.current | 0.5 | 9.982 | ok |
| dut_dmm.dc_voltage | 5.018 | 9.985 | ok |
| dut_load.ch1.voltage | 4.946 | 9.981 | ok |
| dut_load.ch1.current | 1.001 | 9.981 | ok |

Every channel in the spec passes the check.

## End the session

```ipython3
# Closing an instrument stops its daemon and flushes its stream to Nominal.
for instrument in [psu, dmm, eload]:
    instrument.close()
# End the run.
run.update(end=pd.Timestamp.now(tz="UTC").ceil("s"))
bench
```

```
Asset(rid='ri.scout.cerulean-staging.asset.cb545fdc-997d-d062-bbc0-c84979601495', name='Bench 7', description='Power-conversion test bench', properties=mappingproxy({'example': 'bench_bringup', 'bench_id': 'bench-7'}), labels=('bench',), created_at=1793820135019024000, is_archived=False)
```

Asset **Bench 7** [Open in Nominal ↗](https://app.gov.nominal.io/w/ri.security.cerulean-staging.workspace.1870868e-c361-fd28-4bd5-e1b2325eb774/assets/ri.scout.cerulean-staging.asset.cb545fdc-997d-d062-bbc0-c84979601495)

| RID | `ri.scout.cerulean-staging.asset.cb545fdc-997d-d062-bbc0-c84979601495`⧉ |
| --- | --- |
| Description | Power-conversion test bench |
| Created | 2026-11-04 19:22:15 UTC |
| Labels | bench |

Properties (2)

| bench_id | bench-7 |
| --- | --- |
| example | bench_bringup |

RelationshipsDatasets (1)

| Data scope | Dataset | Start | End |
| --- | --- | --- | --- |
| `bench` | [Bench 7 instruments](https://app.gov.nominal.io/w/ri.security.cerulean-staging.workspace.1870868e-c361-fd28-4bd5-e1b2325eb774/datasets/ri.catalog.cerulean-staging.dataset.c47a79c8-590b-64f8-45c1-038b6164a2b9)⧉ | 2026-11-06 19:11:59 UTC | 2026-11-06 19:11:59 UTC |

Runs (1)

| Run | Name | Start | Duration |
| --- | --- | --- | --- |
| #473 | [Bench 7 bring-up](https://app.gov.nominal.io/w/ri.security.cerulean-staging.workspace.1870868e-c361-fd28-4bd5-e1b2325eb774/runs/ri.scout.cerulean-staging.run.38b3cbf7-d469-d6ac-04cc-316a4aa22f39)⧉ | 2026-11-06 19:11:59 UTC | 51 s |

The bench’s card shows its run and its dataset. Over the run, the DMM steps from 12 V to 5 V when
its leads move:

```ipython3
import matplotlib.pyplot as plt

plt.style.use("dark_background")

session = datasource_to_dataframe(
    dataset,
    channels=[dataset.get_channel("dut_dmm.dc_voltage")],
    start=run.start,
    end=run.end,
)
session.index = pd.to_datetime(session.index)
session.plot(style=".", figsize=(12, 3), ylabel="V", legend=False);
```

![../_images/6ae2a930af1f9a88437bdb94bff3cc481b5e491de666aaa601ebe8495703d634.png](https://dev.nominal.io/demos/_images/6ae2a930af1f9a88437bdb94bff3cc481b5e491de666aaa601ebe8495703d634.png)
