Mapping Vendor Commands to a Canonical SCPI Layer

A canonical command layer replaces the scattered vendor dialects your orchestration code would otherwise embed with one internal vocabulary — measure_voltage_dc, set_output_state — that every driver resolves through a declarative table. This guide narrows the broader SCPI command-set standardization problem to the table itself: how a Keysight, Tektronix, or Rigol dialect (and non-SCPI legacy verbs) maps onto a single vocabulary your application targets.

The distinction from standardizing SCPI command sets across mixed hardware matters. That guide builds the deterministic boundary that folds divergent responses into predictable Python types. This one sits one level up: it defines the vocabulary — a bidirectional map from canonical intent to wire command and from wire response back to a canonical value — so application code never names a vendor verb. The two compose: the canonical layer decides what to send; the normalization boundary guarantees how the answer parses. Assume each instrument already has a session allocated through a configured VISA Resource Manager, and that genuinely non-SCPI devices are first wrapped by a protocol abstraction layer that exposes a SCPI-shaped surface for this map to target.

The Mapping Function and Its Inverse

Treat the layer as a pair of total functions over a canonical verb set C and a vendor set V. The forward map produces a wire command; the inverse decodes a wire response back into a canonical, unit-normalized value:

Round-trip symmetry is the correctness contract. For any canonical verb c issued to vendor v, encoding then decoding must recover the canonical quantity the caller intended, within the instrument’s tolerance band:

Completeness is the coverage metric that gates a driver’s readiness. If V_req(c) is the set of verbs an application requires and a vendor map supplies V_map(v), then

A driver with completeness below 1.0 has a known gap — a verb the vendor cannot serve — which the layer must surface at registration, not discover mid-run when application code calls a verb that silently falls through to a wrong default. Computing this ratio at load time turns “does this instrument support what my pipeline needs?” into a deterministic assertion rather than a runtime surprise.

A Declarative Per-Vendor Mapping Table

The table is data, not code: each canonical verb points at a per-vendor entry carrying a command template, the wire unit that template speaks, and a response parser. Keeping it declarative means adding a vendor is a data edit, and completeness is computable by set arithmetic over the keys.

Fan-in of vendor dialects through a canonical mapping table into one internal vocabulary On the left, four sources — Keysight SCPI (MEAS:VOLT:DC?), Tektronix SCPI (MEASU:IMM:VAL?), Rigol SCPI (MEAS:VOLT:DC? in millivolts), and a legacy non-SCPI verb set (RDVDC) — each feed arrows into a central canonical mapping table. The table holds rows keyed by canonical verb, each with a per-vendor template, a wire unit, and a response parser, plus argument and unit normalization. A single arrow leaves the table on the right into one internal vocabulary node exposing measure_voltage_dc, set_output_state, and read_error, which the application code targets. A dashed return path labelled inverse decode carries wire responses back through the table. VENDOR DIALECTS Keysight MEAS:VOLT:DC? · V Tektronix MEASU:IMM:VAL? · V Rigol MEAS:VOLT:DC? · mV Legacy RDVDC (non-SCPI) Canonical Mapping Table verb → template · unit · parser measure_voltage_dc unit-normalize → volts set_output_state bool → ON | OFF | 1 | 0 read_error fallback if unmapped encode inverse decode INTERNAL VOCAB application code targets one vocabulary measure_voltage_dc set_output_state read_error

Each vendor dialect and the legacy verb set fan in through one declarative table; application code targets only the internal vocabulary on the right, and the dashed path carries responses back through the inverse decode.

Argument and unit normalization live in the table, not in application code. A caller passes measure_voltage_dc and expects volts; if Rigol reports millivolts, the entry’s response parser divides by 1000 so the canonical value is always volts. Symmetrically, a setter that takes volts must scale into the vendor’s wire unit before formatting. Fixing the wire unit per entry is what makes the round-trip symmetry equation above hold across a mixed rack.

A Canonical Translator Driven by a Declarative Map

The translator below is a thin engine over the declarative COMMAND_MAP. It resolves a canonical verb for the bound vendor, normalizes arguments into wire units, formats the template, and — for queries — runs the entry’s response parser to return a canonical value. Verbs absent for a vendor raise at call time with an explicit UnmappedVerb, never a silent fallthrough.

from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Any, Callable, Protocol

logger = logging.getLogger("canonical_translator")


class Transport(Protocol):
    """Minimal message-based transport (a pyvisa MessageBasedResource satisfies this)."""

    def query(self, cmd: str) -> str: ...
    def write(self, cmd: str) -> None: ...


@dataclass(frozen=True)
class CommandEntry:
    """One canonical verb realized for one vendor dialect."""

    template: str                              # e.g. "MEAS:VOLT:DC?" or "VOLT {value:.4f}"
    is_query: bool
    encode: Callable[[dict[str, Any]], dict[str, Any]] | None = None  # canonical -> wire args
    decode: Callable[[str], Any] | None = None                        # wire response -> canonical


class UnmappedVerb(KeyError):
    """The bound vendor has no entry for the requested canonical verb."""


class DecodeError(ValueError):
    """A wire response could not be decoded into a canonical value."""


# Declarative table: canonical verb -> {vendor -> CommandEntry}.
# Keysight/Tektronix report volts; Rigol reports millivolts and is scaled on decode.
COMMAND_MAP: dict[str, dict[str, CommandEntry]] = {
    "measure_voltage_dc": {
        "keysight": CommandEntry("MEAS:VOLT:DC?", True, decode=float),
        "tektronix": CommandEntry("MEASU:IMM:VAL?", True, decode=float),
        "rigol": CommandEntry("MEAS:VOLT:DC?", True, decode=lambda r: float(r) / 1000.0),
    },
    "set_output_state": {
        "keysight": CommandEntry(
            "OUTP {value}", False,
            encode=lambda a: {"value": "ON" if a["on"] else "OFF"},
        ),
        "rigol": CommandEntry(
            ":OUTP:STAT {value}", False,
            encode=lambda a: {"value": "1" if a["on"] else "0"},
        ),
    },
}


class CanonicalTranslator:
    """Bidirectional map from a canonical vocabulary onto one vendor's wire dialect."""

    def __init__(self, vendor: str, transport: Transport,
                 command_map: dict[str, dict[str, CommandEntry]] = COMMAND_MAP) -> None:
        self._vendor = vendor.lower()
        self._io = transport
        self._map = command_map

    def completeness(self, required: set[str]) -> float:
        """Fraction of required canonical verbs this vendor can serve (0.0-1.0)."""
        served = {v for v in required if self._vendor in self._map.get(v, {})}
        return len(served) / len(required) if required else 1.0

    def _entry(self, verb: str) -> CommandEntry:
        try:
            return self._map[verb][self._vendor]
        except KeyError as exc:
            raise UnmappedVerb(f"{verb!r} not mapped for vendor {self._vendor!r}") from exc

    def call(self, verb: str, **canonical_args: Any) -> Any:
        """Encode a canonical verb to the wire, execute, and decode the response."""
        entry = self._entry(verb)
        wire_args = entry.encode(canonical_args) if entry.encode else canonical_args
        command = entry.template.format(**wire_args)

        if not entry.is_query:
            self._io.write(command)
            logger.debug("wrote %s -> %s", verb, command)
            return None

        raw = self._io.query(command).strip()
        if entry.decode is None:
            return raw
        try:
            return entry.decode(raw)
        except (ValueError, TypeError) as exc:
            raise DecodeError(f"{verb!r} on {self._vendor!r}: cannot decode {raw!r}") from exc

Because the map is data, a fourth vendor is a dict entry with no engine change, and completeness reduces to set membership over its keys. Argument normalization is confined to encode; response normalization to decode. Neither leaks into the application, which only ever writes translator.call("measure_voltage_dc") and receives volts.

Validating Round-Trip Symmetry Across Two Vendor Maps

The validation that matters is the round trip: issue the same canonical verb against two vendor maps whose wire units differ, and assert both decode to the same canonical quantity within tolerance. A fake transport lets this run in CI without a rig, and the identical assertion runs unchanged against live sessions.

import pytest


class FakeTransport:
    """Replays a scripted wire response for the exact command a vendor emits."""

    def __init__(self, responses: dict[str, str]) -> None:
        self._responses = responses
        self.written: list[str] = []

    def query(self, cmd: str) -> str:
        return self._responses[cmd]

    def write(self, cmd: str) -> None:
        self.written.append(cmd)


def test_measure_voltage_dc_round_trips_across_vendors() -> None:
    # Keysight answers in volts, Rigol in millivolts; both must decode to 12.34 V.
    keysight = CanonicalTranslator("keysight", FakeTransport({"MEAS:VOLT:DC?": "12.34"}))
    rigol = CanonicalTranslator("rigol", FakeTransport({"MEAS:VOLT:DC?": "12340.0"}))

    v_k = keysight.call("measure_voltage_dc")
    v_r = rigol.call("measure_voltage_dc")

    assert abs(v_k - v_r) <= 1e-4 * abs(v_k) + 1e-3   # r_tol*|v| + a_tol band
    assert isinstance(v_k, float) and isinstance(v_r, float)


def test_completeness_flags_missing_verb() -> None:
    # Tektronix has no set_output_state entry -> completeness < 1.0, surfaced at load.
    tek = CanonicalTranslator("tektronix", FakeTransport({}))
    required = {"measure_voltage_dc", "set_output_state"}
    assert tek.completeness(required) == pytest.approx(0.5)
    with pytest.raises(UnmappedVerb):
        tek.call("set_output_state", on=True)

On a live rack, drive the same assertion against a shorted or reference source and confirm every bound vendor lands inside the tolerance band; log the decoded type to prove float is returned uniformly. Gate driver startup on completeness(required) == 1.0 so a rack with a missing verb refuses to run rather than failing four hours in. Classify whatever the instrument’s error queue reports through error code categorization so a read_error decode is never mistaken for a value.

Failure Modes Specific to the Mapping Layer

  • Unit mismatch (V versus mV). A vendor reports millivolts but its decode is a bare float, so measure_voltage_dc returns 12340.0 where 12.34 is expected — a silent 1000× error that passes every type check. Diagnosis: the round-trip test above fails the tolerance band across vendors. Fix the wire unit in the entry’s decode, and add a per-vendor unit assertion to the map audit.
  • Vendor lacking a canonical capability. Application code calls set_output_state on a bench multimeter that has no output stage. Diagnosis: completeness(required) < 1.0 at load, or UnmappedVerb at call. Never paper over this with a default that maps to an unrelated command — surface it at registration and route the workload to a capable instrument.
  • Response-format divergence. One firmware echoes the query header (VOLT 12.34) so float("VOLT 12.34") raises inside decode. Diagnosis: DecodeError names the verb, vendor, and raw payload. Strip the header in that entry’s decode, or apply the deterministic parsing boundary from standardizing SCPI command sets across mixed hardware before the canonical decode runs.
  • Silent fallthrough to a wrong default. A .get(verb, some_default) in an earlier map design returned a benign-looking no-op for an unmapped verb, so a missing setter silently did nothing and the DUT stayed unpowered. Diagnosis: the setter’s write list is empty in a replay test. The _entry method here raises UnmappedVerb precisely to make this impossible — never reintroduce a defaulting lookup on the command map.

A canonical layer earns its keep by making the vendor boundary explicit and total: every application verb resolves to exactly one wire command per vendor or raises, every response decodes to one canonical unit, and completeness is a number you assert at load rather than a hope you carry into an overnight run.

References

← Back to SCPI Command-Set Standardization