Add native QCustomPlot renderer and shared plot adapters

This commit is contained in:
2026-10-07 11:33:14 +03:00
parent 90046de7fd
commit 79b0cde20e
23 changed files with 44815 additions and 35 deletions
+122 -20
View File
@@ -45,7 +45,7 @@ class DPO4034:
self.manager = None
self.instrument = None
def connect(self, resource, timeout_ms=5000):
def connect(self, resource, timeout_ms=15000):
self.close()
factory = self._factory
if factory is None:
@@ -62,8 +62,8 @@ class DPO4034:
self.instrument.read_termination = "\n"
identity = self.instrument.query("*IDN?").strip()
fields = identity.upper().split(",")
if len(fields) < 2 or "TEKTRONIX" not in fields[0] or fields[1].strip() != "DPO4034":
raise ValueError(f"Ожидался Tektronix DPO4034, получено: {identity}")
if len(fields) < 2 or "TEKTRONIX" not in fields[0] or fields[1].strip() not in ("DPO4034", "MDO4034C"):
raise ValueError(f"Ожидался Tektronix DPO4034 или MDO4034C, получено: {identity}")
self.instrument.write("HEADER OFF")
return identity
except Exception:
@@ -80,38 +80,140 @@ class DPO4034:
if manager is not None:
manager.close()
def capture(self, channels, points=100000):
@staticmethod
def _channel(channel):
if channel not in ("CH1", "CH2", "CH3", "CH4"):
raise ValueError("Выберите канал CH1…CH4")
return channel
def settings(self, channel="CH1"):
self._channel(channel)
if self.instrument is None:
raise RuntimeError("Прибор не подключён")
commands = ["TRIGGER:A:TYPE?", "TRIGGER:A:MODE?", "TRIGGER:A:EDGE:SOURCE?",
"TRIGGER:A:EDGE:SLOPE?", f"TRIGGER:A:LEVEL:{channel}?",
"HORIZONTAL:SCALE?", f"SELECT:{channel}?", f"{channel}:SCALE?",
f"{channel}:POSITION?", f"{channel}:COUPLING?", "ACQUIRE:MODE?",
"ACQUIRE:NUMAVG?", "ACQUIRE:STATE?", "TRIGGER:STATE?", "HORIZONTAL:RECORDLENGTH?"]
values = self.instrument.query(";:".join(commands)).strip().split(";")
if len(values) != len(commands):
raise ValueError("Некорректный ответ настроек прибора")
keys = ("trigger_type", "trigger_mode", "trigger_source", "trigger_slope", "trigger_level",
"time_scale", "channel_enabled", "channel_scale", "channel_position", "coupling",
"acquire_mode", "averages", "running", "trigger_state", "record_length")
result = dict(zip(keys, (v.strip().strip('"') for v in values)))
result["channel"] = channel
for key in ("trigger_level", "time_scale", "channel_scale", "channel_position"):
result[key] = float(result[key])
if not math.isfinite(result[key]):
raise ValueError("Некорректное числовое значение настроек")
for key in ("channel_enabled", "averages", "running", "record_length"):
result[key] = int(result[key])
source = result["trigger_source"]
if source in ("CH1", "CH2", "CH3", "CH4") and source != channel:
result["trigger_level"] = float(self.instrument.query(f"TRIGGER:A:LEVEL:{source}?").strip())
return result
def configure(self, section, values, channel="CH1"):
self._channel(channel)
if self.instrument is None:
raise RuntimeError("Прибор не подключён")
def number(key, positive=False):
value = float(values[key])
if not math.isfinite(value) or (positive and value <= 0):
raise ValueError("Некорректное значение: " + key)
return format(value, ".12g")
def choice(key, allowed):
value = values[key]
if value not in allowed:
raise ValueError("Некорректное значение: " + key)
return value
if section == "preset":
from .tektronix_presets import PRESETS
if values.get("preset") not in PRESETS:
raise ValueError("Неизвестный пресет")
preset = PRESETS[values["preset"]]
commands = ["ACQUIRE:MODE " + preset["mode"],
"HORIZONTAL:RECORDLENGTH " + str(preset["record_length"])]
if preset["live"]:
commands += ["SELECT:CH1 ON", "ACQUIRE:STOPAFTER RUNSTOP", "ACQUIRE:STATE RUN"]
elif section == "trigger":
source = self._channel(values["source"])
commands = ["TRIGGER:A:TYPE EDGE", "TRIGGER:A:MODE " + choice("mode", ("AUTO", "NORMAL")),
"TRIGGER:A:EDGE:SOURCE " + source,
"TRIGGER:A:EDGE:SLOPE " + choice("slope", ("RISE", "FALL", "EITHER")),
f"TRIGGER:A:LEVEL:{source} " + number("level")]
channel = source
elif section == "horizontal":
commands = ["HORIZONTAL:SCALE " + number("scale", True)]
elif section == "record":
length = int(values["length"])
if not 1000 <= length <= 20000000:
raise ValueError("Длина записи должна быть 1000…20000000 точек")
commands = [f"HORIZONTAL:RECORDLENGTH {length}"]
elif section == "channel":
commands = [f"SELECT:{channel} " + choice("enabled", ("ON", "OFF")),
f"{channel}:SCALE " + number("scale", True),
f"{channel}:POSITION " + number("position"),
f"{channel}:COUPLING " + choice("coupling", ("AC", "DC", "GND"))]
elif section == "acquisition":
averages = int(values["averages"])
if averages not in (2, 4, 8, 16, 32, 64, 128, 256, 512):
raise ValueError("Число усреднений: 2…512, степень двойки")
commands = ["ACQUIRE:MODE " + choice("mode", ("SAMPLE", "AVERAGE", "HIRES")),
f"ACQUIRE:NUMAVG {averages}"]
else:
raise ValueError("Неизвестный раздел настроек")
self.instrument.write(";:".join(commands))
return self.settings(channel)
def acquisition(self, action, channel="CH1"):
self._channel(channel)
if self.instrument is None:
raise RuntimeError("Прибор не подключён")
commands = {"run": "ACQUIRE:STOPAFTER RUNSTOP;:ACQUIRE:STATE RUN",
"stop": "ACQUIRE:STATE STOP",
"single": "ACQUIRE:STATE STOP;:ACQUIRE:STOPAFTER SEQUENCE;:ACQUIRE:STATE RUN"}
if action not in commands:
raise ValueError("Неизвестная команда сбора")
self.instrument.write(commands[action])
# Do not wait for *OPC?: a single acquisition may wait indefinitely for a trigger.
return self.settings(channel)
def capture(self, channels, points=100000, live=False):
channels = tuple(channels)
if not channels or len(set(channels)) != len(channels) or any(c not in ("CH1", "CH2", "CH3", "CH4") for c in channels):
raise ValueError("Выберите каналы CH1…CH4")
if not 1 <= points <= 1000000:
raise ValueError("Число точек должно быть 1…1000000")
if live and len(channels) != 1:
raise ValueError("Быстрое чтение поддерживает один канал")
if not 1 <= points <= 2000000:
raise ValueError("Число точек должно быть 1…2000000")
scope = self.instrument
if scope is None:
raise RuntimeError("Прибор не подключён")
# Freeze the record so all selected channels belong to the same capture.
running = int(scope.query("ACQUIRE:STATE?").strip())
running = 0 if live else int(scope.query("ACQUIRE:STATE?").strip())
result = []
try:
scope.write("ACQUIRE:STATE STOP")
scope.query("*OPC?")
if not live:
scope.query("ACQUIRE:STATE STOP;*OPC?")
for channel in channels:
if not int(scope.query(f"SELECT:{channel}?").strip()):
scope.write(f"DATA:SOURCE {channel};:DATA:ENCDG RIBINARY;:DATA:WIDTH 2;:DATA:START 1;:DATA:STOP {points}")
keys = ("PT_FMT", "XINCR", "XZERO", "PT_OFF", "YMULT", "YOFF", "YZERO", "YUNIT", "NR_PT")
query = f"SELECT:{channel}?;" + ";".join(f":WFMOUTPRE:{key}?" for key in keys)
fields = scope.query(query).strip().split(";")
if len(fields) != 10:
raise ValueError("Некорректный ответ преамбулы осциллограммы")
if not int(fields[0]):
raise ValueError(f"Включите {channel} на осциллографе")
scope.write(f"DATA:SOURCE {channel}")
scope.write("DATA:ENCDG RIBINARY")
scope.write("DATA:WIDTH 2")
scope.write("DATA:START 1")
scope.write(f"DATA:STOP {points}")
if scope.query("WFMOUTPRE:PT_FMT?").strip().upper() != "Y":
if fields[1].strip().upper() != "Y":
raise ValueError("ENV/Peak Detect не поддерживается; выберите Sample или Average")
params = [float(scope.query(f"WFMOUTPRE:{key}?").strip()) for key in
("XINCR", "XZERO", "PT_OFF", "YMULT", "YOFF", "YZERO")]
params = [float(value) for value in fields[2:8]]
dx, x0, offset, mult, yoff, y0 = params
if not all(math.isfinite(v) for v in params) or dx <= 0:
raise ValueError("Некорректная преамбула осциллограммы")
unit = scope.query("WFMOUTPRE:YUNIT?").strip().strip('"')
count = int(scope.query("WFMOUTPRE:NR_PT?").strip())
unit = fields[8].strip().strip('"')
count = int(fields[9].strip())
if not 1 <= count <= points:
raise ValueError("Некорректная длина осциллограммы")
raw = scope.query_binary_values("CURVE?", datatype="h", is_big_endian=True,