> ## Documentation Index
> Fetch the complete documentation index at: https://instro.nominal.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrate to instro (v1)

> Move your codebase from nominal-instro (<=0.6.x) to instro (>=0.7)

<Info>
  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](#automated-codemod)), the v1.0
  channel-naming standardization (no code change required: set
  [`legacy_naming=True`](/instrumentation/library#backwards-compatible-channel-naming-legacy_naming)
  on each instrument to preserve the pre-v1.0 wire format),
  [NI physical channel naming](#ni-physical-channel-naming) (NI DAQmx channels
  must now be fully qualified, with no compatibility flag), and the
  [DAQ digital channel configuration](#daq-digital-channel-configuration) split
  (`configure_digital_channel` becomes `configure_digital_line` /
  `configure_digital_port`).
</Info>

## What changed

The Python package `nominal-instro` has been renamed to `instro`. This affects:

1. **PyPI distribution names**: `nominal-instro` → `instro`, 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. `NominalDAQ` → `InstroDAQ`). 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](#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-instro`                   | `instro`              |
| `nominal-instro-daq-ni`            | `instro-daq-ni`       |
| `nominal-instro-daq-labjack`       | `instro-daq-labjack`  |
| `nominal-instro-daq-mcc`           | `instro-daq-mcc`      |
| `nominal-instro-i2c-aardvark`      | `instro-i2c-aardvark` |
| `nominal-instro-unstable`          | `instro-unstable`     |
| `nominal-instro-ethernetip-python` | `instro-ethernetip`   |

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 import                                                            | New import                                                    |
| --------------------------------------------------------------------- | ------------------------------------------------------------- |
| `from nominal_instro.instruments.daq import NominalDAQ`               | `from instro.daq import InstroDAQ`                            |
| `from nominal_instro.instruments.daq.driver import DAQDriverBase`     | `from instro.daq import DAQDriverBase`                        |
| `from nominal_instro.drivers.daq.keysight import Keysight34980A`      | `from instro.daq.drivers import Keysight34980A`               |
| `from nominal_instro.drivers.daq.labjack import LabJackTSeriesDriver` | `from instro.daq.drivers.labjack import LabJackTSeriesDriver` |
| `from nominal_instro.instruments.dmm import NominalDMM`               | `from instro.dmm import InstroDMM`                            |
| `from nominal_instro.drivers.dmm.keithley import Keithley2400`        | `from instro.dmm.drivers import Keithley2400`                 |
| `from nominal_instro.instruments.eload import NominalELoad`           | `from instro.eload import InstroELoad`                        |
| `from nominal_instro.drivers.eload.bk import BK85XXB`                 | `from instro.eload.drivers import BK85XXB`                    |
| `from nominal_instro.instruments.psu import NominalPSU`               | `from instro.psu import InstroPSU`                            |
| `from nominal_instro.drivers.psu.rigol import RigolDP800`             | `from instro.psu.drivers import RigolDP800`                   |
| `from nominal_instro.instruments.i2c import NominalI2C`               | `from instro.i2c import I2CInterface`                         |
| `from nominal_instro.drivers.i2c.totalphase import Aardvark`          | `from instro.i2c.drivers.totalphase import Aardvark`          |
| `from nominal_instro.protocols.modbus import NominalModbus`           | `from instro.modbus import ModbusDevice`                      |
| `from nominal_instro.lib import VisaDriver, Instrument`               | `from instro.lib import VisaDriver, Instrument`               |
| `from nominal_instro.protocols.common_types import DeviceInfo`        | `from 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

| Old                                | New                         |
| ---------------------------------- | --------------------------- |
| `NominalDAQ`                       | `InstroDAQ`                 |
| `NominalDMM`                       | `InstroDMM`                 |
| `NominalPSU`                       | `InstroPSU`                 |
| `NominalI2C`                       | `I2CInterface`              |
| `NominalELoad`                     | `InstroELoad`               |
| `NominalModbus`                    | `ModbusDevice`              |
| `NominalScope`                     | `InstroScope`               |
| `NominalInstrument`                | `Instrument`                |
| `NominalInstrumentationErrorCodes` | `InstrumentationErrorCodes` |
| `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).

| Removed              | Removed in | Migration                                                                  |
| -------------------- | ---------- | -------------------------------------------------------------------------- |
| `NominalDMMFacade`   | INSTRO-158 | Drop the facade. Driver methods take the configuration they need directly. |
| `NominalPSUFacade`   | INSTRO-157 | Drop the facade. Driver methods take the configuration they need directly. |
| `NominalI2CFacade`   | INSTRO-156 | Drop the facade. Driver methods take the configuration they need directly. |
| `NominalScopeFacade` | INSTRO-171 | Drop the facade. Driver methods take the configuration they need directly. |
| `NominalDAQFacade`   | INSTRO-311 | Drop 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

<CodeGroup>
  ```bash curl theme={null}
  curl -O https://raw.githubusercontent.com/nominal-io/instro/main/docs/guides/migration/migrate_to_instro.py
  ```

  ```bash wget theme={null}
  wget https://raw.githubusercontent.com/nominal-io/instro/main/docs/guides/migration/migrate_to_instro.py
  ```
</CodeGroup>

### 2. Preview the changes

Always start with a dry run so you can review what will change.

```bash theme={null}
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

```bash theme={null}
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

```bash theme={null}
# 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](#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](#channel-name-changes) below for the recommended path.

After the codemod runs, search for anything it missed:

```bash theme={null}
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.

### Recommended: set `legacy_naming=True` and migrate at your own pace

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.

```python theme={null}
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](/instrumentation/library#backwards-compatible-channel-naming-legacy_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.

| Category            | Old                                                                                | New                                                                                 |
| ------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| 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.

```bash theme={null}
# 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:

```bash theme={null}
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:

```python theme={null}
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`).

```python theme={null}
# 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](#daq-digital-channel-configuration) below). NI's [physical channel naming reference](https://www.ni.com/docs/en-US/bundle/ni-daqmx/page/physchannames.html#GUID-7CB1537B-6D48-41A9-BF7E-5510456B6CB4) 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](https://www.ni.com/en/support/documentation/supplemental/21/what-is-ni-measurement---automation-explorer--ni-max--.html) or the [NI Hardware Configuration Utility](https://www.ni.com/docs/en-US/bundle/hardwareconfigurationutility/page/definition.html).

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.

```python theme={null}
# 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

```bash theme={null}
git checkout .
```

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

## Source

The codemod script lives at
[`docs/guides/migration/migrate_to_instro.py`](https://github.com/nominal-io/instro/blob/main/docs/guides/migration/migrate_to_instro.py)
in the `instro` repository. File issues or improvements against that path.
