Publish pymeasure instruments with instro#
Publish the commands you send to a pymeasure instrument, and the values you read from it, through any instro publisher. The examples use pymeasure’s simulated instruments, so they need no hardware, and write CSV files to temporary folders. The guide covers the options.
pip install "instro[pymeasure]"
From a clone of the repository, just notebook opens this file in JupyterLab: open pymeasure_publishing.md with Open With → Jupytext Notebook.
Wrap an instrument#
PymeasureInstrument makes a pymeasure instrument an instro instrument. Setting a property publishes a command on <name>.<property>.cmd, and reading one publishes a measurement on <name>.<property>.
import tempfile
import pandas as pd
from pymeasure.instruments.fakes import SwissArmyFake
from instro.lib import FilePublisher
from instro.pymeasure import PymeasureInstrument
publisher = FilePublisher(directory=tempfile.mkdtemp(), format="csv")
with PymeasureInstrument(name="bench", instrument=SwissArmyFake(wait=0), publishers=[publisher]) as bench:
bench.device.output_voltage = 5.0 # bench.device is the pymeasure instrument
bench.device.voltage
pd.read_csv(publisher.file_path)[["channel", "value"]]
| channel | value | |
|---|---|---|
| 0 | bench.output_voltage.cmd | 5.0 |
| 1 | bench.voltage | 10.0 |
Attach to existing pymeasure code#
attach() adds publishing to an instrument your code already uses. The instrument stays connected after you close the recorder.
from instro.pymeasure import attach
fake = SwissArmyFake(wait=0) # existing code
publisher = FilePublisher(directory=tempfile.mkdtemp(), format="csv")
recorder = attach(instrument=fake, name="bench", publishers=[publisher]) # added
for volts in [1.0, 2.0, 3.0]: # existing code, unchanged
fake.output_voltage = volts
print(fake.voltage)
recorder.close() # added: flushes and closes the publisher
pd.read_csv(publisher.file_path)[["channel", "value"]]
10
10
10
| channel | value | |
|---|---|---|
| 0 | bench.output_voltage.cmd | 1.0 |
| 1 | bench.voltage | 10.0 |
| 2 | bench.output_voltage.cmd | 2.0 |
| 3 | bench.voltage | 10.0 |
| 4 | bench.output_voltage.cmd | 3.0 |
| 5 | bench.voltage | 10.0 |
Poll in the background#
After start(), the background daemon reads each property in poll every background_interval seconds and publishes it. get_channel() returns the latest values.
from instro.lib import BackgroundDaemonConfig
with PymeasureInstrument(
name="bench",
instrument=SwissArmyFake(wait=0),
publishers=[FilePublisher(directory=tempfile.mkdtemp(), format="csv")],
poll=["wave"], # SwissArmyFake.wave is a sine of elapsed time
background_config=BackgroundDaemonConfig(interval=0.1),
) as bench:
bench.start()
for _ in range(3):
print(bench.get_channel(channel_name="wave", wait_for_new_samples=True).latest)
0.10285632045722183
0.20190059406271593
0.29897622131296664
Drivers with channels and SCPI commands#
This example runs the KeysightE36312A driver against pymeasure’s ProtocolAdapter, which replies to each SCPI command from a list. On a bench, pass the VISA resource instead: KeysightE36312A(adapter="USB0::0x2A8D::0x1102::MY12345678::INSTR").
from pymeasure.adapters import ProtocolAdapter
from pymeasure.instruments.keysight import KeysightE36312A
adapter = ProtocolAdapter(
comm_pairs=[
("OUTPut 1, (@1)", None), # (command sent, instrument reply)
("VOLT 2.5, (@1)", None),
("VOLT? (@1)", "2.5"),
("MEAS:CURRent? (@1)", "0.25"),
]
)
publisher = FilePublisher(directory=tempfile.mkdtemp(), format="csv")
with PymeasureInstrument(name="psu", instrument=KeysightE36312A(adapter=adapter), publishers=[publisher]) as psu:
psu.device.ch_1.output_enabled = True
psu.device.ch_1.voltage_setpoint = 2.5 # control, set: command
psu.device.ch_1.voltage_setpoint # control, read back: measurement
psu.device.ch_1.current # measurement
pd.read_csv(publisher.file_path)[["channel", "value"]]
| channel | value | |
|---|---|---|
| 0 | psu.ch1.output_enabled.cmd | 1.00 |
| 1 | psu.ch1.voltage_setpoint.cmd | 2.50 |
| 2 | psu.ch1.voltage_setpoint | 2.50 |
| 3 | psu.ch1.current | 0.25 |
The attribute ch_1 becomes ch1 in the channel name, and booleans publish as 1.0 and 0.0. Setting voltage_setpoint publishes psu.ch1.voltage_setpoint.cmd, and reading it back publishes psu.ch1.voltage_setpoint, so the value you asked for stays apart from the value the instrument reports. The guide lists the properties instro skips.
Choose channel names#
A ChannelMap names each property’s channel, or leaves the property unpublished. Generate one, edit it, save it next to your code, and pass it as channel_map=. preview() shows the command and measurement channel of each property.
from instro.pymeasure import ChannelMap
channel_map = ChannelMap.generate(target=KeysightE36312A)
pd.DataFrame(channel_map.preview(target=KeysightE36312A, name="psu")).head(5)
| property | kind | command | measurement | |
|---|---|---|---|---|
| 0 | ch_1.current | measurement | None | psu.ch1.current |
| 1 | ch_1.current_limit | control | psu.ch1.current_limit.cmd | psu.ch1.current_limit |
| 2 | ch_1.output_enabled | control | psu.ch1.output_enabled.cmd | psu.ch1.output_enabled |
| 3 | ch_1.voltage | measurement | None | psu.ch1.voltage |
| 4 | ch_1.voltage_setpoint | control | psu.ch1.voltage_setpoint.cmd | psu.ch1.voltage_setpoint |
KeysightE36312A declares its channels on the class, so the class is enough. For a driver that adds channels when it connects, pass the connected instrument. select() and exclude() take glob patterns, and rename() sets names. Anything that matches nothing raises KeyError.
channel_map.select("ch_1.*") # glob patterns
channel_map.rename(names={"ch_1.voltage_setpoint": "out1.v_set", "ch_1.current": "out1.i"})
map_path = f"{tempfile.mkdtemp()}/psu_map.json" # in your project, save it next to your code
channel_map.save(path=map_path, target=KeysightE36312A) # adds kind, SCPI commands and docs for hand edits
preview = pd.DataFrame(channel_map.preview(target=KeysightE36312A, name="psu"))
preview.dropna(subset=["command", "measurement"], how="all")
| property | kind | command | measurement | |
|---|---|---|---|---|
| 0 | ch_1.current | measurement | None | psu.out1.i |
| 1 | ch_1.current_limit | control | psu.ch1.current_limit.cmd | psu.ch1.current_limit |
| 2 | ch_1.output_enabled | control | psu.ch1.output_enabled.cmd | psu.ch1.output_enabled |
| 3 | ch_1.voltage | measurement | None | psu.ch1.voltage |
| 4 | ch_1.voltage_setpoint | control | psu.out1.v_set.cmd | psu.out1.v_set |
Edit the saved file by hand, or generate it from the terminal with instro pymeasure map keysight.KeysightE36312A -o psu_map.json. Pass the file or the ChannelMap as channel_map= to PymeasureInstrument or attach(). The guide describes the format.
adapter = ProtocolAdapter(
comm_pairs=[("VOLT 2.5, (@1)", None), ("MEAS:CURRent? (@1)", "0.25"), ("VOLT 1, (@2)", None)]
)
publisher = FilePublisher(directory=tempfile.mkdtemp(), format="csv")
with PymeasureInstrument(
name="psu",
instrument=KeysightE36312A(adapter=adapter),
publishers=[publisher],
channel_map=map_path,
) as psu:
psu.device.ch_1.voltage_setpoint = 2.5
psu.device.ch_1.current
psu.device.ch_2.voltage_setpoint = 1 # sent to the instrument, not published
pd.read_csv(publisher.file_path)[["channel", "value"]]
| channel | value | |
|---|---|---|
| 0 | psu.out1.v_set.cmd | 2.50 |
| 1 | psu.out1.i | 0.25 |
Publish from a pymeasure procedure#
Attach in startup() and close the recorder in shutdown(). pymeasure still writes its results file.
from pymeasure.experiment import FloatParameter, Procedure, Results, Worker, unique_filename
publisher = FilePublisher(directory=tempfile.mkdtemp(), format="csv")
class VoltageSweep(Procedure):
max_voltage = FloatParameter("Max voltage", units="V", default=5)
DATA_COLUMNS = ["Output voltage (V)", "Voltage (V)"]
def startup(self):
self.instrument = SwissArmyFake(wait=0)
self.recorder = attach(instrument=self.instrument, name="sweep", publishers=[publisher])
def execute(self):
for step in range(6):
self.instrument.output_voltage = self.max_voltage * step / 5
self.emit(
"results",
{"Output voltage (V)": self.instrument.output_voltage, "Voltage (V)": self.instrument.voltage},
)
def shutdown(self):
self.recorder.close()
results = Results(procedure=VoltageSweep(), data_filename=unique_filename(directory=tempfile.mkdtemp()))
worker = Worker(results=results)
worker.start()
worker.join(timeout=30)
pd.read_csv(publisher.file_path).groupby("channel").value.count()
channel
sweep.output_voltage 6
sweep.output_voltage.cmd 6
sweep.voltage 6
Name: value, dtype: int64
Choose a publisher#
publishers= takes any instro publisher. To publish to a Nominal dataset, use a NominalCorePublisher, or pass dataset_rid= to use your default Nominal profile:
from instro.lib import NominalCorePublisher
recorder = attach(instrument=fake, name="bench", publishers=[NominalCorePublisher(dataset_rid="<dataset rid>")])
# or, with your default profile
recorder = attach(instrument=fake, name="bench", dataset_rid="<dataset rid>")