Skip to main content
This is v1 of the migration guide. It covers four changes that shipped in the same migration window: the mechanical rename from nominal-instro to instro (automated via the codemod), the v1.0 channel-naming standardization (no code change required: set legacy_naming=True on each instrument to preserve the pre-v1.0 wire format), NI physical channel naming (NI DAQmx channels must now be fully qualified, with no compatibility flag), and the DAQ digital channel configuration split (configure_digital_channel becomes configure_digital_line / configure_digital_port).

What changed

The Python package nominal-instro has been renamed to instro. This affects:
  1. PyPI distribution names: nominal-instroinstro, and every workspace package (nominal-instro-daq-ni, nominal-instro-daq-labjack, etc.) drops its nominal- prefix.
  2. Python import path: from nominal_instro...from instro....
  3. Class names: instrument facades drop the Nominal prefix in favor of Instro (e.g. NominalDAQInstroDAQ). The base class NominalInstrument becomes Instrument. NominalI2C and NominalModbus become I2CInterface and ModbusDevice (bare protocol names, no Instro prefix: see Folder layout).
  4. Folder layout: the library now uses a category-first layout: each category owns its instrument class and its drivers together (instro/daq/, instro/dmm/, etc.), and I2C and Modbus lift to their own top-level packages because they are protocols, not role-abstractions. See Folder layout & import paths below.
Two classes intentionally keep the Nominal prefix because the prefix refers to the Nominal platform they integrate with, not the package namespace:
  • NominalCorePublisher
  • NominalConnectPublisher

Package rename table

Old (<=0.6.x)New (>=0.7)
nominal-instroinstro
nominal-instro-daq-niinstro-daq-ni
nominal-instro-daq-labjackinstro-daq-labjack
nominal-instro-daq-mccinstro-daq-mcc
nominal-instro-i2c-aardvarkinstro-i2c-aardvark
nominal-instro-unstableinstro-unstable
nominal-instro-ethernetip-pythoninstro-ethernetip-python
Extras (pip install nominal-instro[daq]) keep the same extras names, just on the new package: pip install instro[daq].

Folder layout & import paths

The old nominal_instro/instruments/<category>/ + nominal_instro/drivers/<category>/<vendor>/ split has been collapsed into one folder per category, with concrete drivers living under each category’s own drivers/ subfolder. Two protocols (I2C and Modbus) leave instruments/protocols for top-level packages because the protocol is the device identity, with no swappable wire format underneath.
Old importNew import
from nominal_instro.instruments.daq import NominalDAQfrom instro.daq import InstroDAQ
from nominal_instro.instruments.daq.driver import DAQDriverBasefrom instro.daq import DAQDriverBase
from nominal_instro.drivers.daq.keysight import Keysight34980Afrom instro.daq.drivers import Keysight34980A
from nominal_instro.drivers.daq.labjack import LabJackTSeriesDriverfrom instro.daq.drivers.labjack import LabJackTSeriesDriver
from nominal_instro.instruments.dmm import NominalDMMfrom instro.dmm import InstroDMM
from nominal_instro.drivers.dmm.keithley import Keithley2400from instro.dmm.drivers import Keithley2400
from nominal_instro.instruments.eload import NominalELoadfrom instro.eload import InstroELoad
from nominal_instro.drivers.eload.bk import BK85XXBfrom instro.eload.drivers import BK85XXB
from nominal_instro.instruments.psu import NominalPSUfrom instro.psu import InstroPSU
from nominal_instro.drivers.psu.rigol import RigolDP800from instro.psu.drivers import RigolDP800
from nominal_instro.instruments.i2c import NominalI2Cfrom instro.i2c import I2CInterface
from nominal_instro.drivers.i2c.totalphase import Aardvarkfrom instro.i2c.drivers.totalphase import Aardvark
from nominal_instro.protocols.modbus import NominalModbusfrom instro.modbus import ModbusDevice
from nominal_instro.lib import VisaDriver, Instrumentfrom instro.lib import VisaDriver, Instrument
from nominal_instro.protocols.common_types import DeviceInfofrom instro.lib.types import DeviceInfo
The conventions are:
  • Each category root (instro.daq, instro.dmm, …) re-exports the instrument class and the category base driver, so custom-driver authors only need to remember one import path per category.
  • Concrete drivers shipped in the main instro distribution live flat under <category>/drivers/ (no per-vendor subfolder). Workspace packages like instro-daq-labjack keep their vendor folder (instro.daq.drivers.labjack) because that’s where their __init__.py lives.
  • instro.lib is the home of cross-category building blocks: VisaDriver, Instrument, Measurement, LinearScale, etc.

Class rename table

OldNew
NominalDAQInstroDAQ
NominalDMMInstroDMM
NominalPSUInstroPSU
NominalI2CI2CInterface
NominalELoadInstroELoad
NominalModbusModbusDevice
NominalScopeInstroScope
NominalInstrumentInstrument
NominalInstrumentationErrorCodesInstrumentationErrorCodes
NominalCorePublisher(unchanged)
NominalConnectPublisher(unchanged)

Removed classes

These facade classes were removed entirely as part of the INSTRO-231 driver-shape refactor. They have no replacement. The codemod deliberately leaves these names unchanged so you hit a clear ImportError and can migrate away from the facade pattern (drivers now compose VisaDriver directly).
RemovedRemoved inMigration
NominalDMMFacadeINSTRO-158Drop the facade. Driver methods take the configuration they need directly.
NominalPSUFacadeINSTRO-157Drop the facade. Driver methods take the configuration they need directly.
NominalI2CFacadeINSTRO-156Drop the facade. Driver methods take the configuration they need directly.
NominalScopeFacadeINSTRO-171Drop the facade. Driver methods take the configuration they need directly.
NominalDAQFacadeINSTRO-311Drop the facade. Drivers track their own channels and timing state.

Automated codemod

instro ships a standalone Python script that applies all of the above renames to your codebase. It requires no dependencies beyond the standard library and works on any Python 3.10+ installation.

1. Download the script

curl -O https://raw.githubusercontent.com/nominal-io/instro/main/docs/guides/migration/migrate_to_instro.py

2. Preview the changes

Always start with a dry run so you can review what will change.
python migrate_to_instro.py path/to/your/project --dry-run
Sample output:
[DRY-RUN] Migrating /Users/me/my-test-rack
  src/test_rig.py             (12 replacements)
  src/publishers.py           (3 replacements)
  pyproject.toml              (4 replacements)
  requirements.txt            (1 replacements)

Files changed:  4
Total subs:     20
By token:
  nominal_instro                           8
  NominalDAQ                               4
  nominal-instro                           4
  NominalInstrument                        2
  NominalDMM                               2

3. Apply the changes

python migrate_to_instro.py path/to/your/project
The script edits files in place. Run it from a clean git working tree so you can git diff the result and revert with git checkout . if anything looks wrong.

4. Reinstall and verify

# uv
uv sync

# or pip
pip install -e .

# run your tests
pytest

What the codemod handles

The script walks every .py, .pyi, .toml, .md, .mdx, .yml, .yaml, .txt, .cfg, and .ini file (skipping .venv, .git, target, dist, build, __pycache__, .mypy_cache, node_modules, .tox, .pytest_cache) and applies the literal package, import, and class renames. The renames are applied in a fixed order to avoid prefix collisions: for example, NominalInstrumentationErrorCodes is rewritten before NominalInstrument so the longer name isn’t partially mangled. Both NominalCorePublisher and NominalConnectPublisher do not match any rewrite pattern, so they pass through untouched. The codemod does not touch channel-name strings (see Channel name changes). Those are easier to handle via legacy_naming=True, or by hand with the translation table below if you want to fully migrate now.

What you may need to do by hand

The codemod is mechanical. It will not:
  • Update lock files. Re-run uv lock / pip-compile / poetry lock after the migration.
  • Rename files or directories in your repo named after nominal_instro (e.g. my_nominal_instro_helpers.py). Rename those yourself if you have any.
  • Edit binary files such as compiled artifacts, .whl, .so, or images.
  • Adjust CI scripts that wget/curl URLs containing nominal-instro if they live outside your code tree.
  • Migrate channel-name string literals in your code (dashboards, publisher filters, test asserts). See Channel name changes below for the recommended path.
After the codemod runs, search for anything it missed:
rg 'nominal[_-]instro' .
rg '\bNominal(DAQ|DMM|PSU|I2C|ELoad|Modbus|Scope|Instrument)\b' .
The only hits should be inside vendored or generated content you do not control.

Channel name changes

Published channel names changed for PSU, ELoad, I2C, DAQ digital channels, and Scope. The change touches Python source and downstream consumers (dashboards, recorded datasets, alert configs that key off channel names), and the downstream side is the part automation can’t fix. Every category instrument accepts a legacy_naming: bool = False constructor kwarg. Setting it True makes the instrument publish channels under their pre-v1.0 names, with no Python rewrites and no dashboard updates. Migrate the flag-flip first, then update consumers on your schedule.
psu = InstroPSU(name="main", driver=..., num_channels=1, legacy_naming=True)
# publishes  main.ch1_v, main.ch1_v.cmd, main.ch1_i, ...  (pre-v1.0 names)
See Backwards-compatible channel naming for the full feature. The flag is scheduled for removal in v2.0. Use the window to migrate consumers.

After the migration

  • Every part of a channel name is dot-separated. The legacy underscore separator inside I2C channel keys and the leading bare {alias} form for DAQ digital channels are gone.
  • Every command channel ends in .cmd. Previously I2C used _cmd. That’s been normalized.
  • Cryptic single-letter suffixes (_v, _i, _en) on PSU and ELoad telemetry were expanded to descriptive words (.voltage, .current, .enabled).
  • Scope channels switched their separator from _ to .. The abbreviation probe_atten was expanded to probe_attenuation.

Translation table

{name} is the instrument instance name you pass to the constructor. {N} is the channel number. {periph}, {reg}, {field}, and {alias} come from your own configuration.
CategoryOldNew
PSU (telemetry){name}.ch{N}_v{name}.ch{N}.voltage
PSU (telemetry){name}.ch{N}_i{name}.ch{N}.current
PSU (telemetry){name}.ch{N}_en{name}.ch{N}.enabled
PSU (commands){name}.ch{N}_v.cmd{name}.ch{N}.voltage.cmd
PSU (commands){name}.ch{N}_i.cmd{name}.ch{N}.current.cmd
PSU (commands){name}.ch{N}_en.cmd{name}.ch{N}.enabled.cmd
ELoad (telemetry){name}.ch{N}_v / _i{name}.ch{N}.voltage / .current
ELoad (commands){name}.ch{N}_mode.cmd / _level / _short / _range / _slewrate / _enable{name}.ch{N}.mode.cmd / .level / .short / .range / .slewrate / .enabled
I2C (telemetry){name}_{periph}_{reg}[_{field}]{name}.{periph}.{reg}[.{field}]
I2C (commands){name}_{periph}_{reg}_cmd{name}.{periph}.{reg}.cmd
DAQ (digital read)bare {alias}{name}.{alias}
DAQ (digital write)bare {alias}{name}.{alias}.cmd
Scope (telemetry){name}.ch{N}_vrms / _vpp / _vscale / _voffset / _coupling / _waveform{name}.ch{N}.vrms / .vpp / .vscale / .voffset / .coupling / .waveform
Scope (telemetry){name}.ch{N}_probe_atten{name}.ch{N}.probe_attenuation
DMM, Modbus, and DAQ analog/relay channels already followed the standardized convention and need no updates.

Migrating Python source by hand

If you’d rather drop legacy_naming=True entirely and embrace the v1.0 names in your code, the PSU/ELoad/Scope renames are mechanical enough to handle with ripgrep. The patterns below are anchored on a leading .ch followed by digits and a trailing word boundary, so bare identifiers like local_ch1_v = 5 are left alone.
# PSU / ELoad short-letter suffixes
rg -l '\.ch\d+_v\b'  | xargs sed -E -i '' 's/\.ch([0-9]+)_v\b/.ch\1.voltage/g'
rg -l '\.ch\d+_i\b'  | xargs sed -E -i '' 's/\.ch([0-9]+)_i\b/.ch\1.current/g'
rg -l '\.ch\d+_en\b' | xargs sed -E -i '' 's/\.ch([0-9]+)_en\b/.ch\1.enabled/g'

# ELoad descriptive suffixes (separator change only, plus _enable → .enabled)
for suf in mode level short range slewrate; do
  rg -l "\.ch\\d+_${suf}\\b" | xargs sed -E -i '' "s/\.ch([0-9]+)_${suf}\\b/.ch\\1.${suf}/g"
done
rg -l '\.ch\d+_enable\b' | xargs sed -E -i '' 's/\.ch([0-9]+)_enable\b/.ch\1.enabled/g'

# Scope (separator change, plus probe_atten → probe_attenuation)
for suf in vrms vpp vmax vmin vavg vscale voffset coupling waveform frequency; do
  rg -l "\.ch\\d+_${suf}\\b" | xargs sed -E -i '' "s/\.ch([0-9]+)_${suf}\\b/.ch\\1.${suf}/g"
done
rg -l '\.ch\d+_probe_atten\b' | xargs sed -E -i '' 's/\.ch([0-9]+)_probe_atten\b/.ch\1.probe_attenuation/g'
The sed -E -i '' form is BSD/macOS. On GNU sed, use sed -E -i (no empty string). Run on a clean git working tree so you can review the diff. I2C and DAQ digital are not regex-friendly. I2C’s {name}_{periph}_{reg} form requires knowing your peripheral/register names to know where to insert dots. DAQ digital’s bare-alias form is indistinguishable from random other string literals. For those, find references manually:
rg '"[A-Za-z_][A-Za-z0-9_]*_[A-Za-z_][A-Za-z0-9_]*_cmd"'   # I2C-style _cmd string literals
rg 'channel_data\[\s*"[^."]+"\s*\]'                         # channel_data keyed by bare alias (DAQ digital candidate)
Downstream tooling (dashboards, datasets, alerts) that stores channel names by string match needs updating alongside the code. No automation will reach into your dashboard config. That’s the part legacy_naming=True is most valuable for.

Custom instrument authors

If you subclassed Instrument to build your own instrument type, the publish pipeline now goes through two decorators on the base class:
  • @publish_command: wraps methods that return a Command. Publishes the result and raises TypeError if the method returned the wrong type.
  • @publish_measurement: wraps methods that return a Measurement, a list[Measurement], or None. Publishes each item. None is passed through.
Two helpers on Instrument build the right object:
  • self._package_command(channel, data, timestamp, **tags): returns a Command keyed under {self.name}.{channel}. Pass channel with the trailing .cmd so the literal string appears at the call site.
  • self._package_measurement(channel, data, timestamp, **tags): returns a Measurement keyed under {self.name}.{channel}. Do not include .cmd in channel.
A typical set/get method now looks like this:
from instro.lib import Command, Instrument, Measurement
from instro.lib.instrument import publish_command, publish_measurement


class InstroMyThing(Instrument):
    @publish_command
    def set_setpoint(self, value: float, channel: int = 1, **kwargs) -> Command:
        with self._resource_lock:
            self._driver.set_setpoint(value, channel=channel)
            ts = time.time_ns()
        return self._package_command(f"ch{channel}.setpoint.cmd", value, ts, **kwargs)

    @publish_measurement
    def get_readback(self, channel: int = 1, **kwargs) -> Measurement:
        with self._resource_lock:
            value = self._driver.get_readback(channel=channel)
            ts = time.time_ns()
        return self._package_measurement(f"ch{channel}.readback", value, ts, **kwargs)
If you previously copied the old _read_to_command / _read_to_measurement helper pattern into your subclass, delete those copies. The base class methods replace them, and the decorators enforce the channel-naming contract at runtime.

NI physical channel naming

This change is separate from the published-name standardization above. It affects the physical_channel value you pass when configuring a channel, not the name the channel publishes under. legacy_naming does not gate it, and there is no compatibility flag: NI channel strings must be updated before the driver will configure them. The NI DAQmx driver (NIDAQDriver) no longer prepends its device_id constructor argument to physical_channel. The value now reaches NI-DAQmx verbatim, so it must be the fully qualified name (Dev1/ai0, Dev1/port0/line0) rather than the device-relative form (ai0, port0/line0).
# Before: device-relative channel, device_id prepended for you
daq = InstroDAQ(name="myDAQ", driver=NIDAQDriver(device_id="Dev1"))
daq.configure_analog_channel(direction=Direction.INPUT, physical_channel="ai0", alias="ch_0", range_min=0, range_max=5)
daq.configure_digital_line(direction=Direction.OUTPUT, physical_channel="port0/line0", alias="do_0", logic=Logic.HIGH)

# After: fully qualified channel
daq = InstroDAQ(name="myDAQ", driver=NIDAQDriver(device_id="Dev1"))
daq.configure_analog_channel(direction=Direction.INPUT, physical_channel="Dev1/ai0", alias="ch_0", range_min=0, range_max=5)
daq.configure_digital_line(direction=Direction.OUTPUT, physical_channel="Dev1/port0/line0", alias="do_0", logic=Logic.HIGH)
The device_id argument stays on the constructor: the driver still uses it to verify the device is present and to name its DAQmx tasks. Channel aliases and published names are unchanged, so dashboards and datasets keyed off aliases need no update. Only the NI driver is affected. LabJack, MCC, and Keysight already passed physical_channel through verbatim. Pass one fully qualified channel per call. The NI driver hands physical_channel to NI-DAQmx verbatim and configures it as a single channel, so NI-DAQmx range and list syntax (Dev1/ai0:3, Dev1/ai0,Dev1/ai2) is not supported; call configure_analog_channel once per channel instead. Digital ports (Dev1/port0) are supported as a single port channel via configure_digital_port (see DAQ digital channel configuration below). NI’s physical channel naming reference documents how DAQmx names devices, channels, ports, and lines. To confirm the device and channel names on a given system, including ones renamed from the defaults, use NI MAX or the NI Hardware Configuration Utility. There is no reliable regex for these string literals, the same as DAQ digital aliases above. Find your NI channel strings by hand and prefix each with the device name.

DAQ digital channel configuration

InstroDAQ.configure_digital_channel(...) is gone. It used an optional port_width argument to decide at call time whether you were configuring a single line or a whole port. That one entry point is now two, split by shape:
  • configure_digital_line(direction, physical_channel, logic, logic_level=None, alias=None) — a single digital line.
  • configure_digital_port(direction, physical_channel, logic, port_width, logic_level=None, alias=None) — a whole port. port_width is now a required positional argument, not an optional discriminator.
# Before: one method, port_width selects line vs port
daq.configure_digital_channel(direction=Direction.OUTPUT, physical_channel="Dev1/port0/line0", logic=Logic.HIGH, alias="do_0")
daq.configure_digital_channel(direction=Direction.INPUT, physical_channel="Dev1/port0", logic=Logic.HIGH, port_width=DigitalPortWidth.WIDTH_8, alias="di_port")

# After: line and port are separate methods
daq.configure_digital_line(direction=Direction.OUTPUT, physical_channel="Dev1/port0/line0", logic=Logic.HIGH, alias="do_0")
daq.configure_digital_port(direction=Direction.INPUT, physical_channel="Dev1/port0", logic=Logic.HIGH, port_width=DigitalPortWidth.WIDTH_8, alias="di_port")
If you maintain an out-of-tree DAQDriverBase subclass, the driver-side contract changed to match. define_digital_channel, configure_di_channel, and configure_do_channel are removed. Implement the four direction/shape methods instead: configure_di_line_channel and configure_do_line_channel (required), plus configure_di_port_channel and configure_do_port_channel (optional — the base raises NotImplementedError, so override only the ones your hardware supports). Each method parses physical_channel and programs the device directly; the old define_* step and the isinstance/port_width dispatch inside the driver are gone.

Rollback

git checkout .
Or, if you committed before noticing, git revert <commit>.

Source

The codemod script lives at docs/guides/migration/migrate_to_instro.py in the instro repository. File issues or improvements against that path.