Files
templates/python/set_devices/qt_ports/plot_dual_axis.py

195 lines
7.7 KiB
Python

"""Independent amplitude axes for SignalPlot-compatible Qt canvases."""
import math
try:
from PySide6.QtCore import QPointF, Qt
from PySide6.QtGui import QColor
except ImportError:
from PySide2.QtCore import QPointF, Qt
from PySide2.QtGui import QColor
from protocan.plot import Bounds, Viewport
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:
"""Mixin: left axis for large channels, right for small channels.
Shift-wheel selects the axis by the pointer's half of the analog pane.
The host supplies series_bounds/project_series rendering hooks.
"""
def __init__(self, *args, **kwargs):
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):
keys = split_amplitudes(self._series)
if keys != self.right_axis_keys:
self.right_viewport = Viewport()
self.right_axis_keys = keys
super()._sync_bounds()
def set_plot_bounds(self, bounds, has_data=True, x_origin=0):
if self.right_axis_keys and self.has_left_axis():
groups = [[], []]
for series in self._series:
if series.visible and not series.discrete:
groups[series.key in self.right_axis_keys].extend(
v for v in series.values if math.isfinite(v))
ranges = []
for values in groups:
low, high = (min(values), max(values)) if values else (0, 1)
pad = max((high - low) * .08, max(abs(low), abs(high)) * .01, 1e-6)
ranges.append(Bounds(bounds.left, bounds.right, low - pad, high + pad))
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
def plot_rect(self):
rect = super().plot_rect()
if self.right_axis_keys:
rect.setWidth(max(1, rect.width() - 72))
return rect
def right_axis_margin(self):
return 72 if self.right_axis_keys else 0
def reset_viewport(self):
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()
def wheelEvent(self, event):
if not event.modifiers() & Qt.ShiftModifier:
return super().wheelEvent(event)
pos = event.position() if hasattr(event, 'position') else event.posF()
rect = self.value_rect()
if not rect.top() <= pos.y() <= rect.bottom():
event.ignore()
return
ticks = event.angleDelta().y() or event.angleDelta().x()
if not ticks:
ticks = (event.pixelDelta().y() or event.pixelDelta().x()) * 2
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 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()
self.view_changed.emit()
else:
self._navigation_zoom(factor, True, pos)
event.accept()
def paint_axes_and_markers(self, painter):
super().paint_axes_and_markers(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)
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)