# Bench bring-up with instro

Check each instrument’s data as you connect a bench

When you connect a new bench one instrument at a time, check after each one that its channels
reach Nominal under the names your test spec uses, at plausible values and at the rate the test
spec needs. This notebook does that with three virtual [instro](https://instro.nominal.io)
instruments streaming to a Nominal run, so it needs no hardware.

**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.
[Running the notebooks](https://dev.nominal.io/demos/running.md) explains how to set them up.

Each execution of the notebook reuses the bench’s asset. It archives what earlier executions
created on the asset, so the asset holds one run: this execution’s.

## Setup

```ipython3
import time

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from instro.dmm import InstroDMM, MeasurementFunction
from instro.dmm.drivers import VirtualDMM
from instro.eload import InstroELoad, LoadMode
from instro.eload.drivers import VirtualELoad
from instro.lib import set_display_options as set_instro_display_options
from instro.lib.publishers import NominalCorePublisher
from instro.lib.types import BackgroundDaemonConfig
from instro.psu import InstroPSU
from instro.psu.drivers import VirtualPSU
from nominal.core import NominalClient, set_display_options
from nominal.thirdparty.pandas import datasource_to_dataframe

# Nominal's and instro's cards each have display options: show readable timestamps in both.
set_display_options(time_format="readable")
set_instro_display_options(time_format="readable")
plt.style.use("dark_background")

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

The bench powers a power-conversion board, the device under test (DUT):

| Instrument | instro name | Wiring |
| --- | --- | --- |
| Power supply | `bench_psu` | output 1: 12 V into the DUT. Output 2: the fixture’s 5 V rail. |
| DMM | `dut_dmm` | DC voltage across the 5 V rail |
| Electronic load | `dut_load` | sinks 1 A from the DUT’s 5 V output |

Each instrument uses an instro virtual driver. The DMM’s leads were clipped to the wrong output
of the power supply, so it reads 12 V.

```ipython3
psu_hardware = VirtualPSU(num_channels=2, load_resistance={1: 24.0, 2: 10.0})
dmm_hardware = VirtualDMM({MeasurementFunction.DC_VOLTAGE: 12.0})
eload_hardware = VirtualELoad(source_voltage=5.0, source_resistance=0.05)
```

The bench is a Nominal asset, and every instrument streams to the asset’s `bench` dataset. A run
marks the bring-up session. Keep it open in Nominal during bring-up, with **Open in Nominal** on
its card.

```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.d235de0c-1638-15db-43b5-787dbc053e13', name='Bench 7 bring-up', description='', properties=mappingproxy({}), labels=(), links=(), start=1793905812000000000, end=None, run_number=457, assets=('ri.scout.cerulean-staging.asset.cb545fdc-997d-d062-bbc0-c84979601495',), created_at=1793905812404506000, is_archived=False)
```

Run **#457 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.d235de0c-1638-15db-43b5-787dbc053e13)

| RID | `ri.scout.cerulean-staging.run.d235de0c-1638-15db-43b5-787dbc053e13`⧉ |
| --- | --- |
| Start | 2026-11-05 19:10:12 UTC |
| End | ongoing |
| Created | 2026-11-05 19:10:12 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.014cd76d-b001-854c-f57f-5b648a616308)⧉ | no data yet |  |

### The test spec

The test spec names each channel it reads, with the range it expects. It needs every channel at
10 Hz.

```ipython3
SPEC = {
    "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),
}
# Below 10 Hz, because the newest second of data may not have reached Nominal yet.
MIN_RATE_HZ = 5.0
WINDOW_S = 10

def check(window_s: float = WINDOW_S) -> pd.DataFrame:
    """Compare the last ``window_s`` seconds of the bench's data in Nominal with ``SPEC``.

    Args:
        window_s (float): Length of the window that ends now, in seconds.

    Returns:
        pandas.DataFrame: A row for each channel in ``SPEC``, and for each channel of an
        instrument ``SPEC`` doesn't name that sent data in the window. Each row has the
        channel's rate in Hz, its latest value and a status.
    """
    end = pd.Timestamp.now(tz="UTC")
    data = datasource_to_dataframe(
        dataset, start=end - pd.Timedelta(seconds=window_s), end=end
    ).dropna(axis="columns", how="all")
    # Keep the test spec's channels, and every channel of an instrument it doesn't name.
    instruments = {name.split(".")[0] for name in SPEC}
    data = data[[c for c in data if c in SPEC or c.split(".")[0] not in instruments]]
    report = pd.DataFrame(SPEC, index=["low", "high"]).T.join(
        pd.DataFrame(
            {"rate (Hz)": data.count() / window_s, "latest": data.ffill().iloc[-1]}
        ),
        how="outer",
    )
    report["status"] = np.select(
        [
            report["rate (Hz)"].isna(),
            report["low"].isna(),
            report["rate (Hz)"] < MIN_RATE_HZ,
            ~report["latest"].between(report["low"], report["high"]),
        ],
        ["no data", "not in spec", "too slow", "out of range"],
        "ok",
    )
    return report.round(3)
```

## Bring up the bench

Run `check()` after each change on the bench.

### Step 1: the power supply

```ipython3
psu = InstroPSU(
    name="bench_psu",
    driver=psu_hardware,
    num_channels=2,
    # Stream every value to the bench's dataset.
    publishers=[NominalCorePublisher(dataset=dataset)],
    # Read both outputs every 100 ms.
    background_config=BackgroundDaemonConfig(interval=0.1),
)
psu.open()
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()
time.sleep(WINDOW_S)
check()
```

|  | low | high | rate (Hz) | latest | status |
| --- | --- | --- | --- | --- | --- |
| bench_psu.ch1.current | 0.45 | 0.55 | 9.7 | 0.500 | ok |
| bench_psu.ch1.voltage | 11.80 | 12.20 | 9.7 | 11.996 | ok |
| bench_psu.ch2.current | 0.45 | 0.55 | 9.7 | 0.499 | ok |
| bench_psu.ch2.voltage | 4.90 | 5.10 | 9.7 | 5.002 | ok |
| dut_dmm.dc_voltage | 4.90 | 5.10 | NaN | NaN | no data |
| dut_load.ch1.current | 0.95 | 1.05 | NaN | NaN | no data |
| dut_load.ch1.voltage | 4.85 | 5.00 | NaN | NaN | no data |

The power supply’s channels pass. The DMM and the electronic load aren’t connected yet, so their
channels have `no data`.

### Step 2: the DMM

The DMM’s setup comes from another bench’s script, which named the DMM `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(WINDOW_S)
check()
```

|  | low | high | rate (Hz) | latest | status |
| --- | --- | --- | --- | --- | --- |
| bench_psu.ch1.current | 0.45 | 0.55 | 9.2 | 0.500 | ok |
| bench_psu.ch1.voltage | 11.80 | 12.20 | 9.2 | 11.994 | ok |
| bench_psu.ch2.current | 0.45 | 0.55 | 9.2 | 0.500 | ok |
| bench_psu.ch2.voltage | 4.90 | 5.10 | 9.2 | 5.005 | ok |
| dmm.daemon_work_time | NaN | NaN | 9.4 | 0.000 | not in spec |
| dmm.dc_voltage | NaN | NaN | 9.4 | 12.020 | not in spec |
| dmm.loop_time | NaN | NaN | 9.4 | 0.100 | not in spec |
| dut_dmm.dc_voltage | 4.90 | 5.10 | NaN | NaN | no data |
| dut_load.ch1.current | 0.95 | 1.05 | NaN | NaN | no data |
| dut_load.ch1.voltage | 4.85 | 5.00 | NaN | NaN | no data |

`dmm.dc_voltage` is `not in spec`, and `dut_dmm.dc_voltage` has no data. instro names every
channel `<instrument name>.<channel>`, so rename the DMM. As on a real bench, that means editing
the script and connecting the DMM again.

```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()
time.sleep(WINDOW_S)
check()
```

|  | low | high | rate (Hz) | latest | status |
| --- | --- | --- | --- | --- | --- |
| bench_psu.ch1.current | 0.45 | 0.55 | 10.0 | 0.498 | ok |
| bench_psu.ch1.voltage | 11.80 | 12.20 | 10.0 | 12.022 | ok |
| bench_psu.ch2.current | 0.45 | 0.55 | 10.0 | 0.500 | ok |
| bench_psu.ch2.voltage | 4.90 | 5.10 | 10.0 | 4.998 | ok |
| dut_dmm.dc_voltage | 4.90 | 5.10 | 9.2 | 11.974 | out of range |
| dut_load.ch1.current | 0.95 | 1.05 | NaN | NaN | no data |
| dut_load.ch1.voltage | 4.85 | 5.00 | NaN | NaN | no data |

The DMM’s data now arrives as `dut_dmm.dc_voltage`, but it reads 12 V where the test spec expects
the 5 V rail. Move the leads to output 2. The DMM’s card shows the new reading within a second,
without waiting for Nominal.

```ipython3
# In the simulation, moving the leads changes the voltage the virtual DMM reads.
dmm_hardware.stimulus[MeasurementFunction.DC_VOLTAGE] = 5.0
time.sleep(1)
dmm
```

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

InstroDMM **dut_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.014cd76d-b001-854c-f57f-5b648a616308)⧉ | 240 | 0 |  |
| in-memory buffer (3 channels, last 10 samples each) | 239 | 0 |  |

Channels (4)

| Channel | Kind | Latest | Rate | Count | Time |
| --- | --- | --- | --- | --- | --- |
| `daemon_work_time` | measurement | 3.084e-05 | 10/s | 119 | 2026-11-05 19:10:46 UTC |
| `dc_voltage` | measurement | 4.99635 | 10/s | 120 | 2026-11-05 19:10:46 UTC |
| `loop_time` | measurement | 0.100087 | 10/s | 119 | 2026-11-05 19:10:46 UTC |
| `set_measurement_function.cmd` | command | DC_VOLTAGE | 0/s | 1 | 2026-11-05 19:10:34 UTC |

Default tags (1)

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

```ipython3
# Wait for a full window of data from after the move.
time.sleep(WINDOW_S)
check()
```

|  | low | high | rate (Hz) | latest | status |
| --- | --- | --- | --- | --- | --- |
| bench_psu.ch1.current | 0.45 | 0.55 | 9.5 | 0.501 | ok |
| bench_psu.ch1.voltage | 11.80 | 12.20 | 9.0 | 11.995 | ok |
| bench_psu.ch2.current | 0.45 | 0.55 | 9.5 | 0.500 | ok |
| bench_psu.ch2.voltage | 4.90 | 5.10 | 9.0 | 5.014 | ok |
| dut_dmm.dc_voltage | 4.90 | 5.10 | 9.0 | 5.002 | ok |
| dut_load.ch1.current | 0.95 | 1.05 | NaN | NaN | no data |
| dut_load.ch1.voltage | 4.85 | 5.00 | NaN | NaN | no data |

### Step 3: the electronic load

The electronic load’s setup comes from a script that only polled it, so it has no publishers and
its daemon runs at instro’s default interval.

```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(WINDOW_S)
check()
```

|  | low | high | rate (Hz) | latest | status |
| --- | --- | --- | --- | --- | --- |
| bench_psu.ch1.current | 0.45 | 0.55 | 9.1 | 0.500 | ok |
| bench_psu.ch1.voltage | 11.80 | 12.20 | 9.1 | 11.967 | ok |
| bench_psu.ch2.current | 0.45 | 0.55 | 9.1 | 0.499 | ok |
| bench_psu.ch2.voltage | 4.90 | 5.10 | 9.1 | 5.003 | ok |
| dut_dmm.dc_voltage | 4.90 | 5.10 | 9.1 | 5.006 | ok |
| dut_load.ch1.current | 0.95 | 1.05 | NaN | NaN | no data |
| dut_load.ch1.voltage | 4.85 | 5.00 | NaN | NaN | no data |

The electronic load’s channels have no data. Its card shows that it works, but its only publisher
is the **in-memory buffer**, so nothing goes to Nominal. **Background** also runs every second,
where the test spec needs 10 Hz.

```ipython3
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) | 32 | 0 |  |

Channels (7)

| Channel | Kind | Latest | Rate | Count | Time |
| --- | --- | --- | --- | --- | --- |
| `ch1.current` | measurement | 0.999689 | 1/s | 11 | 2026-11-05 19:11:07 UTC |
| `ch1.enabled.cmd` | command | 1 | 0/s | 1 | 2026-11-05 19:10:57 UTC |
| `ch1.level.cmd` | command | 1 | 0/s | 1 | 2026-11-05 19:10:57 UTC |
| `ch1.mode.cmd` | command | CC | 0/s | 1 | 2026-11-05 19:10:57 UTC |
| `ch1.voltage` | measurement | 4.94274 | 1/s | 11 | 2026-11-05 19:11:07 UTC |
| `daemon_work_time` | measurement | 3.794e-05 | 1/s | 10 | 2026-11-05 19:11:07 UTC |
| `loop_time` | measurement | 1.00022 | 1/s | 10 | 2026-11-05 19:11:07 UTC |

Default tags (1)

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

Add a Nominal publisher, and set the daemon to 10 Hz:

```ipython3
eload.add_publisher(NominalCorePublisher(dataset=dataset))
eload.background_interval = 0.1
time.sleep(WINDOW_S)
check()
```

|  | low | high | rate (Hz) | latest | status |
| --- | --- | --- | --- | --- | --- |
| bench_psu.ch1.current | 0.45 | 0.55 | 9.6 | 0.501 | ok |
| bench_psu.ch1.voltage | 11.80 | 12.20 | 9.6 | 11.997 | ok |
| bench_psu.ch2.current | 0.45 | 0.55 | 9.6 | 0.501 | ok |
| bench_psu.ch2.voltage | 4.90 | 5.10 | 9.6 | 5.004 | ok |
| dut_dmm.dc_voltage | 4.90 | 5.10 | 9.6 | 4.994 | ok |
| dut_load.ch1.current | 0.95 | 1.05 | 8.8 | 1.002 | ok |
| dut_load.ch1.voltage | 4.85 | 5.00 | 9.3 | 4.978 | ok |

Every channel in the test 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()
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({'bench_id': 'bench-7', 'example': 'bench_bringup'}), 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.014cd76d-b001-854c-f57f-5b648a616308)⧉ | 2026-11-05 19:10:12 UTC | 2026-11-05 19:11:13 UTC |

Runs (1)

| Run | Name | Start | Duration |
| --- | --- | --- | --- |
| #457 | [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.d235de0c-1638-15db-43b5-787dbc053e13)⧉ | 2026-11-05 19:10:12 UTC | 1 min 9 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
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/89cc49daff138eaf4ee9fa2f9a33b2791b37d95174bb28749f061a8f1f2bba4c.png](https://dev.nominal.io/demos/_images/89cc49daff138eaf4ee9fa2f9a33b2791b37d95174bb28749f061a8f1f2bba4c.png)
