Добавить общее ядро DEIF, эмуляцию приборов и переносимые панели Python/Qt. Расширить загрузчики F2812 и F28335, обновление Flash и профиль STM Invertor. Добавить альтернативный МНК Eigen, модели неисправностей и диагностику сигналов. Обновить справочники устройств, проекты CCS и регрессионные проверки.
212 lines
14 KiB
Python
212 lines
14 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
|
|
from set_devices.plot_ticks import tick_precision # Обе амплитудные шкалы должны различать соседние деления даже при сильном приближении.
|
|
|
|
|
|
def amplitude_axis_margin(widget, bounds): # Отступ правой шкалы зависит от ширины её уточнённых подписей в текущем шрифте виджета.
|
|
step = (bounds.top - bounds.bottom) / max(100, widget.height() / 50) # Консервативная плотность делений даёт запас точности без обращения к геометрии, которая сама вычисляет этот отступ.
|
|
labels = [f'{value:.{tick_precision(value, step)}g}' for value in (bounds.bottom, bounds.top)] # Оцениваем самые длинные крайние подписи в том же формате, что используется при отрисовке шкалы.
|
|
return max(72, 14 + max(widget.fontMetrics().horizontalAdvance(label) for label in labels)) # Прежний минимум 72 пикселя и запас 14 пикселей сохраняют обычный вид и предотвращают обрезание длинных чисел.
|
|
|
|
|
|
def small_amplitude(values, threshold=30):
|
|
extent = getattr(values, "finite_extent", None)
|
|
if extent is not None:
|
|
return max(abs(extent[0]), abs(extent[1])) <= threshold
|
|
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, *, include_hidden=False): # Порог 30 в единицах канала разделяет шкалы; файловый canvas может сохранить в них скрытые ряды.
|
|
"""Channels with finite peak magnitude <= 30 use the right amplitude axis."""
|
|
result = set()
|
|
for channel in series:
|
|
if (not channel.visible and not include_hidden) 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.
|
|
|
|
Ctrl + колесо выбирает шкалу по половине аналоговой области под указателем.
|
|
Shift + ЛКМ перемещает одну Y, выбранную стороной графика при нажатии.
|
|
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._bounds_series(), include_hidden=True) # Выбираем правые каналы из того же набора, что задаёт общие границы: LogsPlot сохраняет в нём скрытые источники.
|
|
if not keys.intersection(self.right_axis_keys): # Сохраняем масштаб при добавлении или временном пересоздании эталона, пока на правой Y остаётся хотя бы один прежний канал.
|
|
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._bounds_series(): # Обе независимые Y соблюдают политику canvas, чтобы снятие флажка не подменяло диапазон токов диапазоном напряжений.
|
|
if not series.discrete: # Дискретные дорожки не имеют общей амплитудной шкалы и не участвуют в её пределах.
|
|
groups[series.key in self.right_axis_keys].extend(
|
|
getattr(series.values, "finite_extent", None) or
|
|
(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(not s.discrete and s.values and s.key not in self.right_axis_keys # Наличие левой координатной системы определяется теми же рядами, что и её границы.
|
|
for s in self._bounds_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() - self.right_axis_margin())) # Уточнённые подписи правой Y получают достаточно места без выхода за границы виджета.
|
|
return rect
|
|
|
|
def right_axis_margin(self):
|
|
return amplitude_axis_margin(self, self.right_visible_bounds) if self.right_axis_keys else 0 # Используем фактический правый диапазон и текущий шрифт; при отсутствии правой шкалы отступ не нужен.
|
|
|
|
def reset_viewport(self):
|
|
self.right_viewport = Viewport()
|
|
super().reset_viewport()
|
|
|
|
def _capture_y_pan(self, event=None): # Снимок содержит области выбранных шкал; None исключает другую шкалу из всего текущего жеста.
|
|
left, right = super()._capture_y_pan(event), self.right_viewport # Сохраняем начальные области до движения, чтобы смещение считалось от точки нажатия без накопления ошибок.
|
|
if event is not None and event.modifiers() & Qt.ShiftModifier and self.right_axis_keys and self.has_left_axis(): # Выбор стороны нужен только для Shift с двумя отдельными Y; Ctrl, стрелки и единственная Y сохраняют общий перенос.
|
|
position = event.position() if hasattr(event, 'position') else event.localPos() # Получаем локальную точку нажатия одинаково для Qt 6 и Qt 5.
|
|
return (None, right) if position.x() >= self.value_rect().center().x() else (left, None) # Центр аналоговой области разделяет стороны; на самой границе выбираем правую Y, как при Ctrl с колесом.
|
|
return left, right # Общие команды перемещения получают обе области, а график с одной Y использует свою основную область.
|
|
|
|
def _apply_y_pan(self, fraction, initial):
|
|
left, right = initial
|
|
if left is not None: # Отсутствующая левая область означает, что Shift выбрал только правую шкалу при нажатии.
|
|
super()._apply_y_pan(fraction, left) # Общий обработчик перемещает левую Y и учитывает её фиксацию без изменения временной оси.
|
|
if right is not None and self.core and not self.lock_y and self.right_axis_keys and self.has_left_axis(): # Правая Y сдвигается лишь при включении в снимок жеста, наличии двух шкал и снятой общей фиксации Y.
|
|
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.ControlModifier: # Ctrl включает масштаб Y выбранной стороны; колесо без Ctrl, включая Shift, передаётся общей навигации по X.
|
|
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(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)
|
|
|
|
|
|
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:.{tick_precision(value, step)}g}') # Сохраняем четыре значащие цифры при обычном масштабе и добавляем разряды по шагу правой шкалы при приближении.
|
|
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() - amplitude_axis_margin(self, self.visible_bounds))) # Единственная правая шкала тренда или спектра получает тот же запас для длинных подписей при приближении.
|
|
return rect
|
|
|
|
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)
|