From 6c08eece61dd00948037738192c3ed325444eaf4 Mon Sep 17 00:00:00 2001 From: Andrey Kruchinkin Date: Sun, 4 Oct 2026 23:34:26 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B4=D0=B2=D0=B5=20=D1=88=D0=BA=D0=B0=D0=BB=D1=8B,?= =?UTF-8?q?=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D1=8E=20?= =?UTF-8?q?=D0=B8=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=D0=BB=D1=8B=20=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/set_devices/plot_formula.py | 31 ++++- python/set_devices/qt_ports/plot_drag.py | 25 +++- python/set_devices/qt_ports/plot_dual_axis.py | 118 ++++++++++++++---- .../set_devices/qt_ports/plot_navigation.py | 12 +- .../set_devices/qt_ports/plot_processing.py | 12 +- 5 files changed, 162 insertions(+), 36 deletions(-) diff --git a/python/set_devices/plot_formula.py b/python/set_devices/plot_formula.py index 6718466..3828241 100644 --- a/python/set_devices/plot_formula.py +++ b/python/set_devices/plot_formula.py @@ -4,6 +4,7 @@ """Local polynomial representation of the curves actually drawn on a plot.""" from dataclasses import dataclass +from functools import lru_cache import math @@ -38,7 +39,7 @@ class FormulaPiece: + f'{number(left)} ≤ x ≤ {number(self.right - origin)} {unit}').rstrip() -def linear_piece(timestamps, values, x): +def linear_piece(timestamps, values, x, window=None): """A source polyline has a linear equation, not an inferred analytic model.""" previous = None for stamp, value in zip(timestamps, values): @@ -48,11 +49,39 @@ def linear_piece(timestamps, values, x): if previous is not None: t0, y0 = previous if t0 <= x <= stamp and stamp > t0: + if window is not None: + left, right = max(t0, window[0]), min(stamp, window[1]) + if not left <= x <= right or left >= right: + return None + slope = (value - y0) / (stamp - t0) + return FormulaPiece(left, right, (y0 + slope * (left - t0), slope * (right - left))) return FormulaPiece(t0, stamp, (y0, value - y0)) previous = stamp, value return None +@lru_cache(maxsize=32) +def window_curve(curve, left, right): + """Reconstruct a formula using only source samples in the focused window.""" + from dataclasses import replace + from .plot_processing import process + request = curve.request + points = tuple((x, y) for x, y in request.series.points if left <= x <= right) + if points == request.series.points: + return curve + if len({x for x, _ in points}) < 2: + return None + try: + return process(replace(request, series=replace(request.series, points=points), x_range=(left, right))) + except ValueError: + return None + + +def window_piece(curve, x, left, right): + local = window_curve(curve, left, right) + return local.piece_at(x) if local is not None and left <= x <= right else None + + def polynomial_coefficients(nodes, values): """Convert evaluations of an existing polynomial to power coefficients.""" divided = list(values) diff --git a/python/set_devices/qt_ports/plot_drag.py b/python/set_devices/qt_ports/plot_drag.py index 0ca0efc..44c91f1 100644 --- a/python/set_devices/qt_ports/plot_drag.py +++ b/python/set_devices/qt_ports/plot_drag.py @@ -14,16 +14,16 @@ except ImportError: DRAG_HINT = ('ЛКМ рядом с маркером — двигать маркер; ЛКМ на свободном поле — ' - 'перемещение по X; Shift + ЛКМ — перемещение по Y. ' + 'перемещение по X; Ctrl + ЛКМ или Shift + ЛКМ — перемещение по Y. ' 'Щелчок мимо маркеров — одиночный курсор.') def vertical_pan_requested(event): - return bool(event.modifiers() & Qt.ShiftModifier) + return bool(event.modifiers() & (Qt.ShiftModifier | Qt.ControlModifier)) def cursor_hit(plot, position, value, event, radius=8): - """Shift always pans; a cursor can only be grabbed near its visible line.""" + """Ctrl/Shift always pan; cursors are grabbed near their visible lines.""" return (not vertical_pan_requested(event) and value is not None and plot.core is not None and plot.has_plot_data and plot.visible_bounds.left <= value <= plot.visible_bounds.right @@ -34,6 +34,19 @@ def cursor_hit(plot, position, value, event, radius=8): class PlotDragInteraction: """Capture a hit marker or pan one axis, chosen at the start of the drag.""" + def _capture_y_pan(self): + return self.viewport + + def _apply_y_pan(self, fraction, initial): + if self.core and not self.lock_y: + self.viewport = initial.transform(self.core, pan_y=fraction) + + def _navigation_pan_y(self, fraction): + self._apply_y_pan(fraction, self._capture_y_pan()) + self._refresh_controls() + self.update() + self.view_changed.emit() + def mousePressEvent(self, event): if event.button() != Qt.LeftButton or not self.core: return super().mousePressEvent(event) @@ -45,6 +58,7 @@ class PlotDragInteraction: self.setFocus(Qt.MouseFocusReason) self._press = QPointF(pos) self._press_viewport = self.viewport + self._press_y_pan = self._capture_y_pan() self._press_markers = self.markers self._press_bounds = self.visible_bounds self._pan_vertical = vertical_pan_requested(event) @@ -80,10 +94,11 @@ class PlotDragInteraction: self.markers = self._press_markers.drag(self.core, marker, delta.y() if marker.horizontal else delta.x(), values.height() if marker.horizontal else rect.width(), self._press_bounds) + elif self._pan_vertical: + self._apply_y_pan(delta.y() / values.height(), self._press_y_pan) else: self.viewport = self._press_viewport.transform(self.core, - pan_x=delta.x() / rect.width() if not self._pan_vertical and not self.lock_x else 0, - pan_y=delta.y() / values.height() if self._pan_vertical and not self.lock_y else 0) + pan_x=delta.x() / rect.width() if not self.lock_x else 0) self._refresh_controls() self.update() diff --git a/python/set_devices/qt_ports/plot_dual_axis.py b/python/set_devices/qt_ports/plot_dual_axis.py index 6443416..6683ab1 100644 --- a/python/set_devices/qt_ports/plot_dual_axis.py +++ b/python/set_devices/qt_ports/plot_dual_axis.py @@ -10,17 +10,20 @@ except ImportError: from protocan.plot import Bounds, Viewport -def split_amplitudes(series, minimum_ratio=10): - """Split at the largest decade gap; zero-valued channels join small ones.""" - magnitudes = sorted((max((abs(v) for v in s.values if math.isfinite(v)), default=0), s.key) - for s in series if s.visible and not s.discrete and len(s.values)) - positive = [(m, key) for m, key in magnitudes if m > 0] - gaps = [(positive[i + 1][0] / positive[i][0], positive[i][0]) - for i in range(len(positive) - 1)] - if not gaps or max(gaps)[0] < minimum_ratio: - return set() - threshold = max(gaps)[1] - return {key for magnitude, key in magnitudes if magnitude <= threshold} +def small_amplitude(values, threshold=30): + magnitude = max((abs(v) for v in values if math.isfinite(v)), default=None) + return magnitude is not None and magnitude <= threshold + + +def split_amplitudes(series, threshold=30): + """Channels with finite peak magnitude <= 30 use the right amplitude axis.""" + result = set() + for channel in series: + if not channel.visible or channel.discrete: + continue + if small_amplitude(channel.values, threshold): + result.add(channel.key) + return result class DualAxisPlot: @@ -33,6 +36,7 @@ class DualAxisPlot: self.right_axis_keys = set() self.right_data_bounds = Bounds(0, 1, 0, 1) self.right_viewport = Viewport() + self.right_axis_limits = None super().__init__(*args, **kwargs) def _sync_bounds(self): @@ -43,7 +47,7 @@ class DualAxisPlot: super()._sync_bounds() def set_plot_bounds(self, bounds, has_data=True, x_origin=0): - if self.right_axis_keys: + if self.right_axis_keys and self.has_left_axis(): groups = [[], []] for series in self._series: if series.visible and not series.discrete: @@ -57,13 +61,50 @@ class DualAxisPlot: bounds, self.right_data_bounds = ranges super().set_plot_bounds(bounds, has_data, x_origin) + def has_left_axis(self): + return any(s.visible and not s.discrete and s.values and s.key not in self.right_axis_keys + for s in self._series) + @property def right_visible_bounds(self): + if not self.has_left_axis(): + return self.visible_bounds right = (self.right_data_bounds.visible(self.core, self.right_viewport) if self.core else self.right_data_bounds) left = self.visible_bounds + if self.lock_y and self.right_axis_limits is not None: + return Bounds(left.left, left.right, *self.right_axis_limits) return Bounds(left.left, left.right, right.bottom, right.top) + def _axis_editor_values(self): + fields = super()._axis_editor_values() + if not self.right_axis_keys: + return fields + if not self.has_left_axis(): + return fields[:2] + [("Y справа min", fields[2][1]), + ("Y справа max", fields[3][1])] + right = self.right_visible_bounds + return fields[:2] + [("Y слева min", fields[2][1]), + ("Y слева max", fields[3][1]), + ("Y справа min", right.bottom), + ("Y справа max", right.top)] + + def _apply_axis_editor_values(self, values): + if len(values) == 6: + # Validate both ranges before changing either one. + Bounds(*values[:4]).validated(self.core) + right = Bounds(*values[:2], *values[4:]).validated(self.core) + self.right_axis_limits = (right.bottom, right.top) + super()._apply_axis_editor_values(values[:4]) + + def _lock_axis(self, axis, locked): + if axis == 'y' and locked and self.right_axis_limits is None: + bounds = self.right_visible_bounds + self.right_axis_limits = (bounds.bottom, bounds.top) + super()._lock_axis(axis, locked) + if axis == 'y' and not locked: + self.right_axis_limits = None + def series_bounds(self, series): return self.right_visible_bounds if series.key in self.right_axis_keys else self.visible_bounds @@ -80,6 +121,15 @@ class DualAxisPlot: self.right_viewport = Viewport() super().reset_viewport() + def _capture_y_pan(self): + return super()._capture_y_pan(), self.right_viewport + + def _apply_y_pan(self, fraction, initial): + left, right = initial + super()._apply_y_pan(fraction, left) + if self.core and not self.lock_y and self.right_axis_keys and self.has_left_axis(): + self.right_viewport = right.transform(self.core, pan_y=fraction) + def reset_interaction(self): self.right_viewport = Viewport() super().reset_interaction() @@ -98,7 +148,7 @@ class DualAxisPlot: if ticks and self.core and not self.lock_y: factor = 1.25 ** max(-8, min(8, ticks / 120)) self.setFocus(Qt.MouseFocusReason) - if self.right_axis_keys and pos.x() >= rect.center().x(): + if self.right_axis_keys and self.has_left_axis() and pos.x() >= rect.center().x(): self.right_viewport = self.right_viewport.transform( self.core, 1, factor, focus_y=(pos.y() - rect.top()) / rect.height()) self.update() @@ -111,14 +161,34 @@ class DualAxisPlot: super().paint_axes_and_markers(painter) if not self.right_axis_keys or not self.core: return - rect, bounds = self.value_rect(), self.right_visible_bounds - painter.save() - painter.setPen(QColor('#a4b8cd')) - step = self.core.tick_step(bounds.top - bounds.bottom, rect.height()) - first = math.ceil(bounds.bottom / step) - count = min(100, max(0, math.floor(bounds.top / step) - first + 1)) - for index in range(count): - value = (first + index) * step - y = rect.top() + bounds.fraction(self.core, value, True) * rect.height() - painter.drawText(QPointF(rect.right() + 7, y + 4), f'{value:.4g}') - painter.restore() + paint_right_axis(painter, self.core, self.value_rect(), self.right_visible_bounds) + + +def paint_right_axis(painter, core, rect, bounds): + painter.save() + painter.setPen(QColor('#a4b8cd')) + step = core.tick_step(bounds.top - bounds.bottom, rect.height() * 2) + first = math.ceil(bounds.bottom / step) + count = min(100, max(0, math.floor(bounds.top / step) - first + 1)) + for index in range(count): + value = (first + index) * step + y = rect.top() + bounds.fraction(core, value, True) * rect.height() + painter.drawText(QPointF(rect.right() + 7, y + 4), f'{value:.4g}') + painter.restore() + + +class SingleAmplitudeAxis: + """Apply the same side policy to a canvas exposing amplitude_values().""" + def has_left_axis(self): + return not small_amplitude(self.amplitude_values()) + + def plot_rect(self): + rect = super().plot_rect() + if not self.has_left_axis(): + rect.setWidth(max(1, rect.width() - 72)) + return rect + + def paint_axes_and_markers(self, painter): + super().paint_axes_and_markers(painter) + if self.core and not self.has_left_axis(): + paint_right_axis(painter, self.core, self.value_rect(), self.visible_bounds) diff --git a/python/set_devices/qt_ports/plot_navigation.py b/python/set_devices/qt_ports/plot_navigation.py index 70e04e9..0fbbe27 100644 --- a/python/set_devices/qt_ports/plot_navigation.py +++ b/python/set_devices/qt_ports/plot_navigation.py @@ -11,7 +11,8 @@ except ImportError: from PySide2.QtWidgets import QScrollArea NAVIGATION_HINT = ('Колесо и +/− — масштаб X; Ctrl + колесо и Ctrl + +/− — масштаб Y. ' - 'ЛКМ на свободном поле — перемещение; ←/→ — перемещение по X; Home/End — начало/конец.') + 'ЛКМ — перемещение по X; Ctrl + ЛКМ — по Y; ' + '←/→ — перемещение по X; ↑/↓ — по Y; Home/End — начало/конец.') class PlotNavigation: @@ -29,6 +30,8 @@ class PlotNavigation: bool(modifiers & Qt.ControlModifier)) elif key in (Qt.Key_Left, Qt.Key_Right) and not (modifiers & Qt.ControlModifier): self._navigation_pan(-.1 if key == Qt.Key_Left else .1) + elif key in (Qt.Key_Up, Qt.Key_Down): + self._navigation_pan_y(-.1 if key == Qt.Key_Up else .1) elif key in (Qt.Key_Home, Qt.Key_End): self._navigation_edge(key == Qt.Key_End) else: @@ -80,6 +83,12 @@ class ScrollPlotNavigation(PlotNavigation): bar.setValue(round(bar.value() + fraction * bar.pageStep())) self.navigation_panned() + def _navigation_pan_y(self, fraction): + scroll = self.scroll_area() + if scroll is not None: + bar = scroll.verticalScrollBar() + bar.setValue(round(bar.value() + fraction * bar.pageStep())) + def _navigation_edge(self, end): scroll = self.scroll_area() if scroll is not None: @@ -113,4 +122,3 @@ class ScrollPlotNavigation(PlotNavigation): self.move_pan(event) self._pan_position = None self.unsetCursor() - diff --git a/python/set_devices/qt_ports/plot_processing.py b/python/set_devices/qt_ports/plot_processing.py index 385de58..bbb3388 100644 --- a/python/set_devices/qt_ports/plot_processing.py +++ b/python/set_devices/qt_ports/plot_processing.py @@ -336,11 +336,13 @@ class PlotProcessingAttachment(QObject): self._dirty = True self._refresh_timer.start(0) - def formula_entries(self, x, origin=0., unit=''): + def formula_entries(self, x, origin=0., unit='', window=None): + from set_devices.plot_formula import window_piece entries = [] offset = self._external_offset if self._external_curve is not None else 0. for curve in self.curves: - piece = curve.piece_at(x + offset) + piece = (window_piece(curve, x + offset, window[0] + offset, window[1] + offset) + if window is not None else curve.piece_at(x + offset)) if piece is not None: color_index = self.panel._result_ids[id(curve)] if self._external_curve is None else 0 text = curve.label + '\n' + piece.text(origin + offset, unit) @@ -369,7 +371,7 @@ class PlotProcessingAttachment(QObject): self.dialog.raise_() self.dialog.activateWindow() - def paint(self, painter, rect, project): + def paint(self, painter, rect, project, project_series=None): curves = self.curves if not curves: return @@ -378,7 +380,9 @@ class PlotProcessingAttachment(QObject): for curve_index, curve in enumerate(curves): path = QPainterPath() for index, (x, y) in enumerate(curve.points): - point = project(x - (self._external_offset if self._external_curve is not None else 0), y, rect) + stamp = x - (self._external_offset if self._external_curve is not None else 0) + point = (project_series(curve.request.series, stamp, y, rect) if project_series + else project(stamp, y, rect)) path.moveTo(point) if index == 0 else path.lineTo(point) color_index = self.panel._result_ids[id(curve)] if self._external_curve is None else 0 color = QColor(RESULT_COLORS[color_index % len(RESULT_COLORS)])