# Simulated Instruments

Try and test instro with no hardware: virtual instruments that run in-process, or bundled SCPI simulators

## Virtual instruments

The quickest way to try `instro`: a virtual driver simulates readings in-process, with no hardware, server, or second terminal. It works with every method of its instrument class.

```python
from instro.dmm import InstroDMM
from instro.dmm.drivers import VirtualDMM

with InstroDMM(name="dmm", driver=VirtualDMM()) as dmm:
    print(dmm.read_dc_voltage().latest)
```

Virtual drivers: `VirtualDMM`, `VirtualELoad`, `VirtualPSU`, `VirtualScope`. In a JSON config, use `"driver": {"connection_type": "virtual", "name": "VirtualDMM"}` (or `"VirtualELoad"`); a PSU or scope also takes its channel count, as in `"driver": {"connection_type": "virtual", "name": "VirtualPSU", "num_channels": 2}` (or `"VirtualScope"`).

## SCPI simulators

`instro` ships local SCPI simulators for select instrument categories, so you can develop and test without owning the physical hardware. Each simulator runs as a TCP server and pairs with a `Simulated<Category>` driver:

```python
from instro.psu import InstroPSU
from instro.psu.drivers import SimulatedPSU

with InstroPSU(
    name="bench_psu",
    driver=SimulatedPSU("TCPIP0::127.0.0.1::5025::SOCKET"),
    num_channels=1,
) as psu:
    psu.set_voltage(5.0, channel=1)
```

## PSU simulator

The PSU simulator is a local SCPI server for development, tests, and demos. It listens on a TCP socket, exposes a programmable power-supply command set, and includes a terminal UI for editing channel loads, probe resistance, and channel limits while your code is running.

### Quickstart

Start the simulator in one terminal:

```bash
python -m instro.psu.scpi_sim_server
```

It binds to `127.0.0.1:5025` by default. If you are working from a source checkout, run the same command through `uv`:

```bash
uv run python -m instro.psu.scpi_sim_server
```

Connect from another terminal or script with the `SimulatedPSU` driver. This small Python client opens an `InstroPSU`, configures channel 1, and reads voltage and current measurements:

```python
from instro.psu import InstroPSU
from instro.psu.drivers import SimulatedPSU

with InstroPSU(
    name="bench_psu",
    driver=SimulatedPSU("TCPIP0::127.0.0.1::5025::SOCKET"),
    num_channels=2,
) as psu:
    psu.set_voltage(5.0, channel=1)
    psu.set_current_limit(1.0, channel=1)
    psu.output_enable(True, channel=1)

    voltage = psu.get_voltage(channel=1)
    current = psu.get_current(channel=1)
    print(f"V: {voltage.latest:.3f} V")
    print(f"I: {current.latest:.3f} A")

    psu.output_enable(False, channel=1)
```

Use CLI flags to change the bind address, port, or initial channel count:

```bash
python -m instro.psu.scpi_sim_server --host 127.0.0.1 --port 5025 --channels 2
```

### Usage

The simulator runs a terminal UI while the TCP server listens in the background. The UI is organized by panel header.

![PSU simulator terminal UI showing the PSU panel, channel panels, and SCPI log](https://dev.nominal.io/instro/_images/psu-simulator-overview.svg)

Filled labels are selectable actions. Use the arrow keys to move between controls. Focused actions use a light highlight. Press Enter to select an action, Escape to close an edit prompt, and `q` to quit.

#### PSU

The **⟢ NOMINAL PSU** panel shows the simulator identity and VISA resource string. Use the resource string when driver code connects to the simulator.

#### Channel

Each **CHANNEL** panel shows the live state for one output. The panel is split into status, voltage, and current sections.

| Header | Functionality |
| --- | --- |
| **STATUS** | Shows output mode, output state, sense mode, and protection trips. Select **REMOVE** to delete the channel from the simulator. |
| **VOLTAGE** | Shows terminal voltage, programmed voltage, voltage limit, OVP state, and OVP level. Select **V LIMIT** to edit the channel voltage ceiling. |
| **CURRENT** | Shows output current, programmed current limit, current limit, OCP state, and OCP level. Select **I LIMIT** to edit the channel current ceiling. |

Changing **V LIMIT** or **I LIMIT** runs `*RST`. The output turns off, programmed voltage and current return to zero, protection state turns off, and OVP/OCP levels return to the configured V/I limits.

#### Probe

The **PROBE** panel shows sense lead resistance. Select **PROBE R** to edit the probe resistance in ohms.

#### Load

The **LOAD** panel shows the external load model. Select **R** to edit load resistance in ohms. Select **EMF** to edit the series electromotive force in volts.

#### + channel

The **+ CHANNEL** panel adds a new output channel with the next available channel ID.

#### SCPI log

The **SCPI LOG** panel records incoming commands, responses, and queued errors. It is read-only.

### Programming guide

Brackets mark optional SCPI path components. For example, `[SOURce:]VOLTage[:LEVel]` accepts `VOLT`, `VOLT:LEV`, `SOUR:VOLT`, and `SOUR:VOLT:LEV`.

Angle brackets mark command arguments. They are notation only; do not include the angle brackets in the command.

- `<NRf+>` is a positive flexible numeric value, such as `0`, `1.25`, or `1e-3`.
- `<literal|literal>` is one literal value from the listed options.

The pipe character (`|`) means OR. For example, `<LOC|REM>` accepts `LOC` or `REM`, and `<NRf+|MIN|MAX>` accepts one numeric value, `MIN`, or `MAX`. Do not send the pipe character in the command.

`MIN` maps to `0`. `MAX` maps to **V LIMIT** for voltage and OVP commands, and **I LIMIT** for current and OCP commands. Do not send `MIN` or `MAX` to queries.

#### Defaults and reset

On startup and after `*RST`, each channel uses the reset operating state:

| Setting | Reset value |
| --- | --- |
| Programmed voltage | `0` |
| Current limit | `0` |
| Output state | `OFF` |
| OVP state | `OFF` |
| OCP state | `OFF` |
| OVP level | `MAX` |
| OCP level | `MAX` |

`*RST` preserves simulator configuration, including channel count, channel IDs, load resistance, series EMF, probe resistance, sense mode, and configured channel limits.

#### Error queue

Errors are queued and returned one at a time by `SYSTem:ERRor?`. The simulator can emit these error codes:

| Code | Message | Cause |
| --- | --- | --- |
| `0` | `No error` | The queue is empty |
| `-108` | `Parameter not allowed` | A query received an argument |
| `-109` | `Missing parameter` | A command was sent without a required argument |
| `-113` | `Undefined header` | The command header is not supported |
| `-114` | `Header suffix out of range` | The command targets a channel that does not exist |
| `-141` | `Invalid character data` | A numeric argument could not be parsed |
| `-222` | `Data out of range` | A numeric input is outside its accepted range |
| `-224` | `Illegal parameter value` | A literal argument is not accepted for that command |
| `301` | `PV Above OVP` | Programmed voltage is above the OVP level |
| `303` | `PC Above OCP` | Current limit is above the OCP level |
| `304` | `OVP Below PV` | OVP level is below programmed voltage |
| `305` | `OCP Below PC` | OCP level is below current limit |
| `323` | `Overcurrent protection tripped` | OCP tripped while enabled |
| `324` | `Overvoltage protection tripped` | OVP tripped while enabled |

#### Common commands

| `*IDN?` | |
| --- | --- |
| Query simulator identity | Response: String |
| `*RST` | |
| Reset channel operating state and clear errors | Response: None |
| `*CLS` | |
| Clear the error queue | Response: None |

#### MEASure subsystem

| `MEASure:CURRent?` | |
| --- | --- |
| Measure output current | Response: Number |
| `MEASure:VOLTage?` | |
| Measure output voltage | Response: Number |

#### OUTPut subsystem

| `OUTPut[:STATe] <0|1|OFF|ON>` | |
| --- | --- |
| Enable or disable output | Response: None |
| `OUTPut[:STATe]?` | |
| Query output state | Response: `0` or `1` |
| `OUTPut:PROTection:CLEar` | |
| Clear latched OVP and OCP faults | Response: None |
| `OUTPut:PROTection:TRIPped?` | |
| Query latched OVP or OCP fault state | Response: `0` or `1` |

#### SOURce subsystem

| `[SOURce:]CURRent[:LEVel][:IMMediate][:AMPLitude] <NRf+|MIN|MAX>` | |
| --- | --- |
| Set current limit | Response: None |
| `[SOURce:]CURRent[:LEVel][:IMMediate][:AMPLitude]?` | |
| Query current limit | Response: Number |
| `[SOURce:]CURRent:PROTection[:LEVel] <NRf+|MIN|MAX>` | |
| Set OCP level | Response: None |
| `[SOURce:]CURRent:PROTection[:LEVel]?` | |
| Query OCP level | Response: Number |
| `[SOURce:]CURRent:PROTection:STATe <0|1|OFF|ON>` | |
| Enable or disable OCP | Response: None |
| `[SOURce:]CURRent:PROTection:STATe?` | |
| Query OCP state | Response: `0` or `1` |
| `[SOURce:]VOLTage[:LEVel][:IMMediate][:AMPLitude] <NRf+|MIN|MAX>` | |
| Set programmed voltage | Response: None |
| `[SOURce:]VOLTage[:LEVel][:IMMediate][:AMPLitude]?` | |
| Query programmed voltage | Response: Number |
| `[SOURce:]VOLTage:PROTection[:LEVel] <NRf+|MIN|MAX>` | |
| Set OVP level | Response: None |
| `[SOURce:]VOLTage:PROTection[:LEVel]?` | |
| Query OVP level | Response: Number |
| `[SOURce:]VOLTage:PROTection:STATe <0|1|OFF|ON>` | |
| Enable or disable OVP | Response: None |
| `[SOURce:]VOLTage:PROTection:STATe?` | |
| Query OVP state | Response: `0` or `1` |

#### SYSTem subsystem

| `SYSTem:ERRor?` | |
| --- | --- |
| Pop one error from the error queue | Response: `code,"message"` |
| `SYSTem:SENSe[:STATe] <LOC|REM>` | |
| Select local or remote sense | Response: None |
| `SYSTem:SENSe[:STATe]?` | |
| Query sense mode | Response: `LOC` or `REM` |

## DMM simulator

The DMM simulator is a local SCPI server for development, tests, and demos. It listens on a TCP socket and exposes a digital-multimeter command set: function switching plus the five primary measurements (DC/AC voltage, DC/AC current, and two-wire resistance). Each function measures a configurable stimulus value returned with Gaussian noise, so repeated reads look like a real bench instrument.

Unlike the [PSU simulator](https://dev.nominal.io/instro/library/simulated-instruments.md#psu-simulator), the DMM simulator is headless: there is no terminal UI. Set stimulus values with CLI flags at startup, or through the Python API when embedding the simulator in tests.

### Quickstart

Start the simulator in one terminal:

```bash
python -m instro.dmm.scpi_sim_server
```

It binds to `127.0.0.1:5026` by default. If you are working from a source checkout, run the same command through `uv`:

```bash
uv run python -m instro.dmm.scpi_sim_server
```

Connect from another terminal or script with the `SimulatedDMM` driver. This small Python client opens an `InstroDMM`, switches functions, and reads measurements:

```python
from instro.dmm import InstroDMM
from instro.dmm.drivers import SimulatedDMM

with InstroDMM(
    name="bench_dmm",
    driver=SimulatedDMM("TCPIP0::127.0.0.1::5026::SOCKET"),
) as dmm:
    print(f"V: {dmm.read_dc_voltage().latest:.4f} V")
    print(f"R: {dmm.read_resistance().latest:.1f} ohm")
```

Use CLI flags to change the bind address, port, or the stimulus each function measures:

```bash
python -m instro.dmm.scpi_sim_server --port 5026 --dc-voltage 3.3 --resistance 4700
```

### Stimulus model

The stimulus is the simulator’s stand-in for the physical quantity on the probes (the DMM analog of the PSU simulator’s load model). Each measurement function has one stimulus value, and every measurement returns that value with Gaussian noise (±0.5% at 3 standard deviations).

| Function | CLI flag | Default |
| --- | --- | --- |
| DC voltage | `--dc-voltage` | `1.25` V |
| AC voltage | `--ac-voltage` | `0.5` V |
| DC current | `--dc-current` | `0.1` A |
| AC current | `--ac-current` | `0.05` A |
| Resistance | `--resistance` | `1000` Ω |

When embedding the simulator in Python (for example in tests), construct the server in-process and set stimulus values at runtime:

```python
from instro.dmm.scpi_sim_server import (
    FUNCTION_RESISTANCE,
    SimulatedDMM,
    SimulatedDMMServer,
)

sim = SimulatedDMM()
server = SimulatedDMMServer(sim, port=0)  # port 0 = pick a free port
server.start()
print(f"TCPIP0::127.0.0.1::{server.port}::SOCKET")

sim.set_stimulus(FUNCTION_RESISTANCE, 4700.0)  # change what the "probes" see

# ... drive it with SimulatedDMM / InstroDMM ...

server.shutdown()
```

Stimulus values persist across `*RST`: reset returns the instrument state (active function, error queue) to defaults, but the simulated physical world stays put.

### Programming guide

Brackets mark optional SCPI path components. For example, `[SENSe:]FUNCtion` accepts `FUNC`, `FUNCTION`, and `SENS:FUNC`. Keywords match in short or long form, in any case.

| Command | Functionality |
| --- | --- |
| `*IDN?` | Returns `NOMINAL,SIMULATED_DMM,000001,1.0`. |
| `*RST` | Resets the active function to DC voltage and clears the error queue. Stimulus values are preserved. |
| `*CLS` | Clears the error queue. |
| `SYSTem:ERRor?` | Pops the oldest queued error, or `0,"No error"`. |
| `[SENSe:]FUNCtion "<function>"` | Sets the active function: `VOLT:DC`, `VOLT:AC`, `CURR:DC`, `CURR:AC`, or `RES`. Bare `VOLT`/`CURR` select DC. Quotes optional. |
| `[SENSe:]FUNCtion?` | Returns the active function, quoted (e.g. `"VOLT:DC"`). |
| `CONFigure:VOLTage[:DC]` | Sets the active function to DC voltage. Same pattern for `:AC`, `CURRent[:DC]`, `CURRent:AC`, and `RESistance`. |
| `MEASure:VOLTage[:DC]?` | Switches to the function and returns a noisy measurement of its stimulus. Same pattern for the other four functions. |
| `READ?` | Returns a noisy measurement using the active function. |

Unknown headers queue `-113,"Undefined header"`; an unknown function name queues `-224,"Illegal parameter value"`. The `SimulatedDMM` driver checks `SYST:ERR?` after every transaction and raises on any queued error.
