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 instruments streaming to a Nominal run, so it needs no hardware.
Requirements: a default profile
for the Nominal Python Client, and the packages the notebook imports, including instro.
Running the notebooks 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#
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 |
|
output 1: 12 V into the DUT. Output 2: the fixture’s 5 V rail. |
DMM |
|
DC voltage across the 5 V rail |
Electronic 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.
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.
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)The test spec#
The test spec names each channel it reads, with the range it expects. It needs every channel at 10 Hz.
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#
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.
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.
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.
# 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)
# 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.
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.
eload
InstroELoad(name='dut_load', driver=VirtualELoad, publishers=[DequeInMemoryPublisher], background_running=True)
Add a Nominal publisher, and set the daemon to 10 Hz:
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#
# 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)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:
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);