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

View File

@@ -9,6 +9,7 @@ See c/set-protocol/include/set_spectrum.h for filtering and normalization semant
"""
from __future__ import annotations
import ctypes
import math
from dataclasses import dataclass
from enum import IntEnum
@@ -63,11 +64,40 @@ class NativeSpectrum:
self._peak.restype = ctypes.c_int
def analyze(self, times, values, *, max_size=4096, window=Window.HANN, filter=Filter.NONE,
low_hz=10.0, high_hz=100.0, remove_mean=True) -> Spectrum:
if type(max_size) is not int or not 16 <= max_size <= 16384 or max_size & (max_size - 1):
raise ValueError("FFT size must be a power of two in 16..16384")
low_hz=10.0, high_hz=100.0, remove_mean=True, full_record=False) -> Spectrum:
if type(max_size) is not int or not 16 <= max_size <= 2097152 or max_size & (max_size - 1):
raise ValueError("FFT size must be a power of two in 16..2097152")
if len(times) != len(values):
raise ValueError("Timestamp and value counts differ")
if full_record and len(times) >= 16:
source_count = len(times)
n = 1 << (min(source_count, max_size).bit_length() - 1)
dt = (times[-1] - times[0]) / (source_count - 1)
if not math.isfinite(dt) or dt <= 0:
raise ValueError("Invalid spectrum timestamps")
if any(not math.isfinite(value) for value in values) or any(
not math.isfinite(times[i]) or abs((times[i] - times[i - 1]) / dt - 1) > .05
for i in range(1, source_count)):
raise ValueError("Full-record FFT requires uniformly spaced finite samples")
# Integrate piecewise-constant samples over equal fractional bins.
# Unlike picking every kth sample, this averages before decimation.
width = source_count / n
averaged = []
index = 0
position = 0.
for output_index in range(n):
end = (output_index + 1) * width
total = 0.
while position < end and index < source_count:
stop = min(end, index + 1.)
total += values[index] * (stop - position)
position = stop
if position >= index + 1:
index += 1
averaged.append(total / width)
start = times[0]
times = [start + ((i + .5) * width - .5) * dt for i in range(n)]
values = averaged
count = min(len(times), max_size)
origin = times[len(times) - count] if count else 0.0
t = (ctypes.c_double * count)(*(value - origin for value in times[-count:])) if count else (ctypes.c_double * 0)()

View File

@@ -0,0 +1,111 @@
"""Software oscilloscope capture and sample measurements (timestamps in ms)."""
from __future__ import annotations
import math
def time_label(milliseconds):
"""Readable time without rounding nanosecond divisions to zero."""
magnitude = abs(milliseconds)
if magnitude < .001:
return f"{milliseconds * 1e6:.6g} нс"
if magnitude < 1:
return f"{milliseconds * 1000:.6g} мкс"
if magnitude < 1000:
return f"{milliseconds:.6g} мс"
return f"{milliseconds / 1000:.6g} с"
def measurements(points):
values = [value for _, value in points if math.isfinite(value)]
if not values:
return None
low, high = min(values), max(values)
mean = math.fsum(value / len(values) for value in values)
scale = max(abs(low), abs(high))
rms = scale * math.sqrt(math.fsum((value / scale) ** 2 for value in values) / len(values)) if scale else 0.
# Hysteresis rejects small excursions around the midpoint. Frequency is
# only reported after two complete rising crossings, never from one edge.
middle = (low + high) / 2
hysteresis = (high - low) * .05
armed = False
crossings = []
previous = None
if high > low:
for stamp, value in points:
if value < middle - hysteresis:
armed = True
if armed and previous and previous[1] < middle <= value and stamp > previous[0]:
t0, v0 = previous
crossings.append(t0 + (stamp - t0) * (middle - v0) / (value - v0))
armed = False
previous = (stamp, value)
period = ((crossings[-1] - crossings[0]) / (len(crossings) - 1)
if len(crossings) >= 2 else None)
return dict(minimum=low, maximum=high, peak_to_peak=high - low, mean=mean,
rms=rms, frequency=1000 / period if period and period > 0 else None)
class SingleCapture:
"""Edge trigger with 20% pretrigger history and 80% posttrigger samples.
Samples are display-scaled; storage/recording remains owned by the caller.
Capture is bounded by the caller's point capacity for each channel.
"""
def __init__(self):
self.cancel()
def cancel(self):
self.armed = False
self.trigger_time = None
self.previous = None
self.data = {}
self.truncated = False
def arm(self, channel, level, falling, span_ms, capacity):
self.cancel()
self.channel = channel
self.level = level
self.falling = falling
self.span_ms = max(.0001, min(60_000., span_ms))
self.capacity = capacity
self.armed = True
def feed(self, stamp, sample, history):
"""Return a complete frame once; history must include this sample."""
if not self.armed:
return None
value = sample.get(self.channel)
if self.trigger_time is None:
previous = self.previous
if value is not None:
self.previous = (stamp, value)
if previous is None or value is None or stamp <= previous[0]:
return None
crossed = (previous[1] > self.level >= value if self.falling
else previous[1] < self.level <= value)
if not crossed:
return None
t0, v0 = previous
self.trigger_time = t0 + (stamp - t0) * (self.level - v0) / (value - v0)
self.left = self.trigger_time - .2 * self.span_ms
self.right = self.trigger_time + .8 * self.span_ms
if callable(history):
history = history()
source = history.get(self.channel, ())
self.truncated = not source or source[0][0] - self.trigger_time > -.2 * self.span_ms
self.data = {key: [(t, v) for t, v in points
if -.2 * self.span_ms <= t - self.trigger_time <= .8 * self.span_ms]
for key, points in history.items()}
else:
for key, value in sample.items():
if -.2 * self.span_ms <= stamp - self.trigger_time <= .8 * self.span_ms:
points = self.data.setdefault(key, [])
if len(points) < self.capacity:
points.append((stamp, value))
else:
self.truncated = True
if stamp - self.trigger_time >= .8 * self.span_ms:
self.armed = False
return self.data
return None

View File

@@ -0,0 +1,80 @@
"""Pixel-column envelope for drawing; measurement samples are never changed."""
import math
from bisect import bisect_left, bisect_right
class IndexedTimes(list):
"""Random-access timestamps, with ordering validated once when loaded."""
def __init__(self, points):
super().__init__(points)
self.ordered = all(math.isfinite(x) and (i == 0 or self[i-1] <= x)
for i, x in enumerate(self))
def viewport_samples(timestamps, values, left, right, neighbours=False, budget=None):
"""Select only the viewport in logarithmic time for indexed records."""
if isinstance(timestamps, (list, tuple, range)) and getattr(timestamps, "ordered", True):
start = bisect_left(timestamps, left)
stop = bisect_right(timestamps, right)
if neighbours:
start = max(0, start - 1)
stop = min(len(timestamps), stop + 1)
stride = max(1, math.ceil((stop-start) / budget)) if budget else 1
for i in range(start, min(stop, len(values)), stride):
yield timestamps[i], values[i]
if budget and stop > start and (stop - 1 - start) % stride:
yield timestamps[stop-1], values[stop-1]
else:
# Streaming or unordered data keeps its original gap/order semantics.
yield from zip(timestamps, values)
def pixel_envelope(timestamps, values, left, right, width):
"""Yield first/min/max/last samples in time order for each pixel column.
NaNs separate paths. Keep one neighbouring sample on either side so clipping
and zooming retain lines crossing the viewport boundaries.
"""
width = max(1, int(width))
scale = width / (right - left) if right > left else 1.
bucket = None
first = low = high = last = None
def emit():
if first is None:
return ()
selected = {point[0]: point for point in (first, low, high, last)}
return tuple((selected[index][1], selected[index][2]) for index in sorted(selected))
for index, (stamp, value) in enumerate(viewport_samples(timestamps, values, left, right, neighbours=True)):
if not math.isfinite(stamp) or not math.isfinite(value):
yield from emit()
yield None
bucket = None
first = low = high = last = None
continue
column = -1 if stamp < left else width if stamp > right else min(width - 1, int((stamp - left) * scale))
point = (index, stamp, value)
if column != bucket:
yield from emit()
bucket = column
first = low = high = last = point
elif column == -1:
first = low = high = last = point
elif column == width:
continue
else:
last = point
if value < low[2]:
low = point
if value > high[2]:
high = point
yield from emit()
def preview_window_count(requested, preview_count, original_count):
"""Translate a raw-sample window to a display-envelope window."""
if not original_count or original_count <= preview_count:
return requested
return max(1, min(preview_count, math.ceil(requested * preview_count / original_count)))

View File

@@ -100,7 +100,10 @@ class PlotDragInteraction:
self.viewport = self._press_viewport.transform(self.core,
pan_x=delta.x() / rect.width() if not self.lock_x else 0)
self._refresh_controls()
self.update()
if self._dragged is not None:
getattr(self, "update_overlay", self.update)()
else:
self.update()
def mouseReleaseEvent(self, event):
if event.button() != Qt.LeftButton:

View File

@@ -157,8 +157,8 @@ class DualAxisPlot:
self._navigation_zoom(factor, True, pos)
event.accept()
def paint_axes_and_markers(self, painter):
super().paint_axes_and_markers(painter)
def paint_axes(self, painter):
super().paint_axes(painter)
if not self.right_axis_keys or not self.core:
return
paint_right_axis(painter, self.core, self.value_rect(), self.right_visible_bounds)
@@ -188,7 +188,7 @@ class SingleAmplitudeAxis:
rect.setWidth(max(1, rect.width() - 72))
return rect
def paint_axes_and_markers(self, painter):
super().paint_axes_and_markers(painter)
def paint_axes(self, painter):
super().paint_axes(painter)
if self.core and not self.has_left_axis():
paint_right_axis(painter, self.core, self.value_rect(), self.visible_bounds)

View File

@@ -0,0 +1,46 @@
"""Queued redraws and cached static layers for Qt plots (Qt 5/6)."""
try:
from PySide6.QtCore import QTimer, Qt
from PySide6.QtGui import QPixmap, QPainter
except ImportError:
from PySide2.QtCore import QTimer, Qt
from PySide2.QtGui import QPixmap, QPainter
class PlotLayers:
def init_plot_layers(self):
self._static_revision = 0
self._static_plot_layer = None
self._redraw_timer = QTimer(self)
self._redraw_timer.setSingleShot(True)
self._redraw_timer.timeout.connect(self._flush_plot_update)
def update(self, *args):
if not hasattr(self, '_redraw_timer'):
return super().update(*args)
self._static_revision += 1
self.update_overlay()
def update_overlay(self):
if not hasattr(self, '_redraw_timer'):
return super().update()
if not self._redraw_timer.isActive():
self._redraw_timer.start(0)
def _flush_plot_update(self):
super().update()
def paint_static_layer(self, painter, draw):
ratio = self.devicePixelRatioF()
key = (self._static_revision, self.width(), self.height(), ratio,
tuple(self.plot_rect().getRect()))
if self._static_plot_layer is None or self._static_plot_layer[0] != key:
image = QPixmap(round(self.width()*ratio), round(self.height()*ratio))
image.setDevicePixelRatio(ratio)
image.fill(Qt.GlobalColor.transparent)
layer = QPainter(image)
try:
draw(layer)
finally:
layer.end()
self._static_plot_layer = key, image
painter.drawPixmap(0, 0, self._static_plot_layer[1])

View File

@@ -64,6 +64,7 @@ class SignalProcessingPanel(QWidget):
self._revision = 0
self._job_revision = 0
self.preserve_on_view_change = False
self.preserve_on_data_change = False
self.export_path = lambda filename: filename
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
@@ -153,7 +154,7 @@ class SignalProcessingPanel(QWidget):
# depend on samples/units, not the source curve's checkbox.
source = replace(source, series=tuple(replace(series, visible=True)
for series in source.series))
if prepare(source, request.series.key, request.method, request.output_count, request.degree) == request:
if self.preserve_on_data_change or prepare(source, request.series.key, request.method, request.output_count, request.degree) == request:
retained.append(result)
if len(retained) != len(self._results):
self._results = retained

View File

@@ -0,0 +1,53 @@
"""Reusable background workers for dense plots and viewport measurements."""
try:
from PySide6.QtCore import QObject, QRunnable, Signal
except ImportError:
from PySide2.QtCore import QObject, QRunnable, Signal
from set_devices.plot_decimation import pixel_envelope, viewport_samples
from set_devices.oscilloscope import measurements
class EnvelopeSignals(QObject):
ready = Signal(str, object, object)
class EnvelopeJob(QRunnable):
def __init__(self, key, geometry, timestamps, values, left, right, width, signals):
super().__init__()
self.args = (timestamps, values, left, right, width)
self.key, self.geometry, self.signals = key, geometry, signals
def run(self):
try:
points = tuple(pixel_envelope(*self.args))
except Exception:
points = None
try:
self.signals.ready.emit(self.key, self.geometry, points)
except RuntimeError:
pass # The plot may have been closed while the worker was running.
class NavigationSignals(QObject):
ready = Signal(int, object, object)
class NavigationJob(QRunnable):
def __init__(self, revision, series, bounds, emitter):
super().__init__()
self.revision, self.series, self.bounds, self.emitter = revision, series, bounds, emitter
def run(self):
stats = {}
try:
for key, times, values in self.series:
points = [(x, y) for x, y in viewport_samples(times, values, self.bounds.left, self.bounds.right)
if self.bounds.left <= x <= self.bounds.right]
stats[key] = measurements(points), len(points)
self.emitter.ready.emit(self.revision, stats, self.bounds)
except Exception:
try:
self.emitter.ready.emit(self.revision, {}, self.bounds)
except RuntimeError:
pass

View File

@@ -0,0 +1,88 @@
"""Isolated native QCustomPlot renderer; no Qt ABI mixing with PySide."""
from array import array
import json
import os
from pathlib import Path
import subprocess
import tempfile
from threading import Lock
try:
from PySide6.QtCore import QObject, QRunnable, Signal
except ImportError:
from PySide2.QtCore import QObject, QRunnable, Signal
class NativePlotSignals(QObject):
ready = Signal(str, object, object)
class NativePlotEngine:
def __init__(self, executable):
self.executable = Path(executable)
self.process = None
self.closed = False
self.lock = Lock()
self.directory = tempfile.TemporaryDirectory(prefix='setgui-qcp-')
self.records = {}
self.serial = 0
def close(self):
self.closed = True
if self.process is not None:
self.process.kill()
if self.process.stdin is not None:
self.process.stdin.close()
# Files are owned by this TemporaryDirectory and released with the engine.
def render(self, times, values, bounds, width, height, color):
with self.lock:
if self.closed:
raise RuntimeError("Plot closed")
if self.process is None or self.process.poll() is not None:
env = os.environ.copy()
env['QT_QPA_PLATFORM'] = 'offscreen'
env.pop('QT_PLUGIN_PATH', None)
env['QT_QPA_PLATFORM_PLUGIN_PATH'] = str(self.executable.parent / 'platforms')
self.process = subprocess.Popen([str(self.executable)], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env, bufsize=0,
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0), cwd=self.executable.parent)
key = (id(times), id(values))
record = self.records.get(key)
if record is None:
self.serial += 1
path = Path(self.directory.name) / f'{self.serial}.bin'
with path.open('wb') as stream:
array('d', times).tofile(stream)
array('d', values).tofile(stream)
record = (path, times, values)
self.records[key] = record
if len(self.records) > 4:
self.records.pop(next(iter(self.records)))
request = dict(file=str(record[0]), count=len(times), width=width, height=height,
left=bounds.left, right=bounds.right, bottom=bounds.bottom, top=bounds.top, color=color)
self.process.stdin.write((json.dumps(request)+'\n').encode())
self.process.stdin.flush()
size = int(self.process.stdout.readline())
if not 0 < size < 100_000_000:
raise RuntimeError('Invalid QCustomPlot frame')
chunks = bytearray()
while len(chunks) < size:
chunk = self.process.stdout.read(size-len(chunks))
if not chunk:
raise RuntimeError('QCustomPlot renderer stopped')
chunks.extend(chunk)
return bytes(chunks)
class NativePlotJob(QRunnable):
def __init__(self, engine, key, geometry, times, values, bounds, width, height, color, signals):
super().__init__()
self.engine, self.key, self.geometry, self.signals = engine, key, geometry, signals
self.args = times, values, bounds, width, height, color
def run(self):
try:
png = self.engine.render(*self.args)
except Exception:
png = None
try:
self.signals.ready.emit(self.key, self.geometry, png)
except RuntimeError:
pass

View File

@@ -0,0 +1,24 @@
"""Tektronix-style record-length selector, including instrument readback."""
try:
from PySide6.QtWidgets import QComboBox
except ImportError:
from PySide2.QtWidgets import QComboBox
from set_devices.tektronix_presets import RECORD_LENGTHS
class RecordLengthBox(QComboBox):
def __init__(self, parent=None):
super().__init__(parent)
for value in RECORD_LENGTHS:
self.addItem(f"{value:,}".replace(",", " ") + " точек", value)
def value(self):
return int(self.currentData())
def setValue(self, value):
value = int(value)
index = self.findData(value)
if index < 0:
self.addItem(f"{value:,}".replace(",", " ") + " точек", value)
index = self.count() - 1
self.setCurrentIndex(index)

View File

@@ -0,0 +1,82 @@
"""Debounced viewport statistics shared by desktop charts."""
import time
from dataclasses import replace
try:
from PySide6.QtCore import QObject, QTimer, QThreadPool, Qt
except ImportError:
from PySide2.QtCore import QObject, QTimer, QThreadPool, Qt
from .plot_workers import NavigationSignals as _NavigationSignals, NavigationJob as _NavigationJob
from set_devices.oscilloscope import time_label
def number(value):
return f"{value:g}"
class ViewportNavigation(QObject):
def __init__(self, chart):
super().__init__(chart)
self.chart = chart
self._navigation_revision = 0
self._navigation_busy = False
self._navigation_emitter = _NavigationSignals(self)
self._navigation_emitter.ready.connect(self._navigation_ready, Qt.ConnectionType.QueuedConnection)
self._navigation_timer = QTimer(self)
self._navigation_timer.setSingleShot(True)
self._navigation_timer.setInterval(180)
self._navigation_timer.timeout.connect(self._navigation_finished)
def _navigation_changed(self):
self._navigation_revision += 1
if max((len(s.values) for s in self.chart.plot._series), default=0) <= 20000:
self.chart._scope_measurements()
self.chart._refresh_processing()
self.chart._refresh_legend()
return
self._navigation_timer.start()
def _navigation_finished(self):
if False:
return
if max((len(s.values) for s in self.chart.plot._series), default=0) <= 20000:
self.chart._scope_measurements()
self.chart._refresh_processing()
return
if self._navigation_busy:
self._navigation_timer.start()
return
self._navigation_busy = True
bounds = self.chart.plot.visible_bounds
panel = self.chart.processing
offset = self.chart._plot_time_offset if not self.chart.fft.isChecked() else 0.
# Navigation changes the next calculation's domain; retained curves keep
# their original domain. Avoid preparing millions of inputs on the UI.
panel._snapshot = replace(panel._snapshot, x_range=(bounds.left + offset, bounds.right + offset))
series = [(s.key, s.timestamps, s.values) for s in self.chart.plot._series if not s.discrete]
QThreadPool.globalInstance().start(_NavigationJob(self._navigation_revision, series, bounds,
self._navigation_emitter))
def _navigation_ready(self, revision, stats, bounds):
self._navigation_busy = False
if False or revision != self._navigation_revision:
return
for row in range(self.chart.legend.rowCount()):
item = self.chart.legend.item(row, 0)
if item is None:
continue
result, _ = stats.get(item.data(Qt.ItemDataRole.UserRole), (None, 0))
for column, key in ((4, "rms"), (5, "maximum"), (6, "minimum")):
cell = self.chart.legend.item(row, column)
if cell is not None:
cell.setText("—" if result is None else "%g" % result[key])
scope = self.chart.scope
result, count = stats.get(scope.channel.currentData(), (None, 0))
if result is None:
scope.readings.setText("Измерения: —")
return
text = " · ".join(f"{title} {number(result[key])}" for title, key in (
("Min", "minimum"), ("Max", "maximum"), ("Размах", "peak_to_peak"),
("Среднее", "mean"), ("RMS", "rms")))
if not self.chart.fft.isChecked():
frequency = result["frequency"]
text += " · f ≈ " + (f"{number(frequency)} Гц" if frequency is not None else "—")
text += f" · {time_label((bounds.right - bounds.left) / 10)}/дел"
scope.readings.setText(text + f" · N={count}")

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,

View File

@@ -0,0 +1,8 @@
"""Shared acquisition presets for supported Tektronix scopes."""
PRESETS = {
"live": dict(label="Живой просмотр", mode="SAMPLE", record_length=10000,
transfer_points=10000, averages=16, live=True, preview=True, repeat=True, pause_ms=0),
"long": dict(label="Длинная запись / FFT", mode="SAMPLE", record_length=1000000,
transfer_points=1000000, averages=16, live=False, preview=True, repeat=False, pause_ms=0),
}
RECORD_LENGTHS = (1000, 10000, 100000, 1000000, 5000000, 10000000, 20000000)

View File

@@ -0,0 +1,79 @@
"""Qt-independent capture queue, preview and full-record CSV export."""
from dataclasses import dataclass
from queue import Queue
from threading import Thread
import csv
from .tektronix import DPO4034, Waveform
from .plot_decimation import pixel_envelope
@dataclass
class CaptureFrame:
full: list
preview: list
def prepare_preview(waveforms, width):
preview = []
for wave in waveforms:
if not wave.seconds:
preview.append(wave)
continue
points = [point for point in pixel_envelope(wave.seconds, wave.values,
wave.seconds[0], wave.seconds[-1], width) if point is not None]
preview.append(Waveform(wave.channel, tuple(x for x, _ in points),
tuple(y for _, y in points), wave.unit))
return preview
class ScopeSession:
"""Serial worker queue: every VISA call, including close, stays off the UI."""
def __init__(self, factory=DPO4034):
self.commands = Queue()
self.results = Queue()
self.thread = Thread(target=self._run, args=(factory,))
self.thread.start()
def _run(self, factory):
client = factory()
try:
while True:
action, args = self.commands.get()
if action == "shutdown":
break
try:
if action == "capture" and len(args) == 4:
value = client.capture(*args[:3])
value = CaptureFrame(value, prepare_preview(value, args[3]))
elif action == "export":
filename, waves = args
with open(filename, "w", newline="", encoding="utf-8-sig") as stream:
writer = csv.writer(stream)
# Native channels share the frozen record's time grid.
# Reject mismatched grids rather than misalign exported values.
if any(w.seconds != waves[0].seconds for w in waves[1:]):
raise ValueError("Для экспорта каналов нужны одинаковые временные сетки")
writer.writerow(["time_ms", *(w.channel + " (" + w.unit + ")" for w in waves)])
for index, stamp in enumerate(waves[0].seconds):
writer.writerow([stamp * 1000, *(w.values[index] for w in waves)])
value = filename
else:
value = getattr(client, action)(*args)
self.results.put((action, value, None))
except Exception as exc:
if action != "export":
try:
client.close()
except Exception:
pass
self.results.put((action, None, str(exc)))
finally:
try:
client.close()
except Exception:
pass # Shutdown must finish even if the device has been unplugged.
def submit(self, action, *args):
self.commands.put((action, args))

View File

@@ -30,6 +30,26 @@ class SpectrumTests(unittest.TestCase):
times = [i / fs for i in range(n)]
return times, [amplitude * sum(math.sin(2 * math.pi * f * t) for f in frequencies) for t in times]
def test_large_fft_above_previous_limit(self):
times, values = self.sample(n=65536, fs=65536, frequencies=(50,))
spectrum = self.core.analyze(times, values, max_size=65536)
self.assertEqual(spectrum.size, 65536)
self.assertAlmostEqual(self.core.dominant_peak(spectrum).frequency_hz, 50., delta=.01)
self.assertEqual(self.core.analyze(times[:1024], values[:1024], max_size=2097152).size, 1024)
def test_full_record_resolves_50_hz_at_high_sample_rate(self):
times, values = self.sample(n=50000, fs=500000, frequencies=(50,), amplitude=.02)
spectrum = self.core.analyze(times, values, max_size=4096, full_record=True)
peak = self.core.dominant_peak(spectrum)
self.assertAlmostEqual(spectrum.sample_rate / spectrum.size, 10., places=5)
self.assertAlmostEqual(peak.frequency_hz, 50., delta=.2)
self.assertAlmostEqual(peak.amplitude, .02, delta=.0002)
self.assertEqual(len(times), 50000)
broken = list(times)
broken[100] = broken[99]
with self.assertRaises(ValueError):
self.core.analyze(broken, values, full_record=True)
def test_fft_matches_independent_direct_dft(self):
times, _ = self.sample(32)
values = [math.sin(i * 1.37) + 0.1 * i for i in range(32)]
@@ -120,7 +140,7 @@ class SpectrumTests(unittest.TestCase):
for n in (0, 1, 15):
with self.assertRaisesRegex(ValueError, "16 samples"):
self.core.analyze(times[:n], values[:n])
for size in (0, 15, 1000, 32768):
for size in (0, 15, 1000, 4194304):
with self.assertRaises(ValueError):
self.core.analyze(times, values, max_size=size)
with self.assertRaises(ValueError):