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.
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
decodeis a barefloat, someasure_voltage_dcreturns 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’sdecode, and add a per-vendor unit assertion to the map audit. - Vendor lacking a canonical capability. Application code calls
set_output_stateon a bench multimeter that has no output stage. Diagnosis:completeness(required) < 1.0at load, orUnmappedVerbat 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) sofloat("VOLT 12.34")raises insidedecode. Diagnosis:DecodeErrornames the verb, vendor, and raw payload. Strip the header in that entry’sdecode, 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’swritelist is empty in a replay test. The_entrymethod here raisesUnmappedVerbprecisely 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.
Related guides
- SCPI Command-Set Standardization — the strategy this vocabulary layer implements.
- Standardizing SCPI command sets across mixed hardware — the response-normalization boundary the decode step composes with.
- Protocol Abstraction Layers — wrap non-SCPI legacy verbs into a mappable surface first.
- VISA Resource Manager Setup — allocate the sessions each translator binds to.
- Error Code Categorization — classify what a decoded error verb reports.
References
- IVI Foundation SCPI specifications — canonical command/response grammar.
- PyVISA documentation — the message-based transport the translator binds to.
← Back to SCPI Command-Set Standardization