Files
templates/python/set_devices/plot_decimation.py
T
Andrey a5e46d4627 Расширить обмен по шинам, CAN-адаптеры и обработку графиков
Добавить очереди операций PBUS/SBUS, общую консоль, настройки Candle и SLCAN, декодеры DSView и обработку плотных графиков. Обновить протокол Бальзам и проверки общих компонентов.
2026-10-17 14:36:14 +03:00

152 lines
17 KiB
Python

"""Pixel-column envelope for drawing; measurement samples are never changed."""
import math
from bisect import bisect_left, bisect_right
from .background_work import work_items # Фоновая подготовка длинных временных массивов должна уступать GIL во время заполнения и проверки порядка.
class PointTimes: # Представление координат X для bisect без параметра key, отсутствующего в Python 3.8 сборки Win7.
def __init__(self, points): # Принимаем индексируемые пары (X, Y), порядок которых проверяется вызывающим кодом.
self.points = points # Храним ссылку на исходные точки, чтобы не копировать большой ряд при каждом изменении окна.
def __len__(self): # Предоставляем bisect длину исходного ряда для определения границ двоичного поиска.
return len(self.points) # Получаем размер за постоянное время без перебора измерений.
def __getitem__(self, index): # Извлекаем одну координату по индексу, выбранному алгоритмом двоичного поиска.
return self.points[index][0] # В паре (X, Y) индекс 0 обозначает время или частоту; значения Y не участвуют в сравнении.
class IndexedTimes(list):
"""Random-access timestamps, with ordering validated once when loaded."""
def __init__(self, points, checkpoint=None): # Необязательная контрольная точка включает кооперативную подготовку только для фоновых файловых заданий.
super().__init__(work_items(points, checkpoint)) # Заполняем полный массив, регулярно освобождая GIL при наличии фонового бюджета.
self.ordered = all(math.isfinite(x) and (i == 0 or self[i-1] <= x) # Конечность и порядок проверяются по всем исходным меткам без прореживания.
for i, x in work_items(enumerate(self), checkpoint)) # Второй длинный проход также уступает интерфейсу и реагирует на отмену.
def viewport_samples(timestamps, values, left, right, neighbours=False, budget=None):
"""Select only the viewport in logarithmic time for indexed records."""
if getattr(timestamps, "ordered", isinstance(timestamps, (list, tuple, range))): # Проверенные индексируемые представления со смещением X также допускают двоичный поиск без копирования меток времени.
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))
index = getattr(values, "envelope_index", None) # Подготовленный файловый ряд содержит индекс точных экстремумов для быстрого выбора экранных столбцов.
if index is not None and index.count == len(values) and getattr(timestamps, "ordered", isinstance(timestamps, (list, tuple, range))): # Индекс применим к неизменённым значениям и проверенным координатам, включая ленивое смещение короткой развёртки.
yield from index.points(timestamps, left, right, width) # Выдаём не более четырёх конечных точек на пиксель плюс соседей для отсечения краёв.
return # Исключаем повторное линейное сканирование миллионов измерений при каждом изменении масштаба.
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 render_envelope(timestamps, values, left, right, width): # Экранная огибающая ограничивает плотный упорядоченный ряд двумя экстремумами на логический пиксель, сохраняя исходные измерения отдельно.
width = max(1, int(width)) # Вызывающий виджет передаёт логическую ширину области: увеличение DPI не должно умножать число почти совпадающих вершин.
ordered = getattr(timestamps, 'ordered', isinstance(timestamps, (list, tuple, range))) # Двоичный поиск и экранные столбцы применимы только к проверенному порядку временных координат.
if not ordered: # Неупорядоченный поток нельзя безопасно переставлять по экранным столбцам или обрезать двоичным поиском.
yield from pixel_envelope(timestamps, values, left, right, width) # Сохраняем прежнюю последовательность и разрывы для нестандартного источника без предположения об упорядоченности.
return # Новый ограниченный экранный режим не меняет семантику неупорядоченных данных.
count = min(len(timestamps), len(values)) # Общая длина исключает обращения за границу при несовпадающих размерах координат и амплитуд.
start, stop = bisect_left(timestamps, left, 0, count), bisect_right(timestamps, right, 0, count) # Находим точное число исходных отсчётов видимого окна без полного обхода файла.
if stop - start <= width: # При сильном увеличении все измерения помещаются в экранный бюджет и не требуют никакого сокращения.
for position in range(max(0, start - 1), min(count, stop + 1)): # Сохраняем по одному соседу для линий, пересекающих края видимого окна.
stamp, value = timestamps[position], values[position] # Извлекаем точную исходную пару, включая нечисловые амплитуды между конечными отсчётами.
yield (stamp, value) if math.isfinite(stamp) and math.isfinite(value) else None # Все конечные точки остаются без изменений, а NaN и бесконечности разрывают путь.
return # Разреженный зум не должен проходить через экстремумы столбцов и терять близкие исходные точки.
index = getattr(values, 'envelope_index', None) # Подготовленный файловый ряд позволяет выбирать экстремумы без повторного линейного обхода миллионов значений.
if index is not None and index.count == len(values): # Индекс допустим только для исходной неизменённой длины ряда.
yield from index.points(timestamps, left, right, width, extrema_only=True) # Сохраняем максимум два экстремума на столбец, два конца окна и два внешних соседа.
return # Быстрый запрос не должен дополнительно запускать линейный запасной путь.
columns = {} # Для неиндексированного ряда храним лишь пару экстремумов на экранный столбец, а не полную запись.
first_visible = last_visible = None # Дополнительно сохраняем два конца видимой линии для правильного соединения с внешними соседями.
segment = 0 # Каждый нечисловой отсчёт увеличивает номер непрерывного участка; разные номера запрещают соединять выбранные точки.
seen = False # Полностью нечисловое окно должно вернуть один разрыв, а пустая запись не создаёт искусственных точек.
scale = width / (right - left) if right > left else 1. # Масштаб переводит время в столбцы; совпадающие временные координаты не допускают деления на ноль.
for ordinal, (stamp, value) in enumerate(viewport_samples(timestamps, values, left, right, neighbours=True)): # Запасной путь читает каждый исходный отсчёт окна ровно один раз и поэтому также сохраняет одиночные пики.
seen = True # Фиксируем наличие хотя бы одного конечного либо нечислового исходного измерения.
if not math.isfinite(stamp) or not math.isfinite(value): # Пропуски не участвуют в экстремумах, но должны разделять любые выбранные точки по разные стороны.
segment += 1 # Номер участка переносит информацию о пропуске без хранения каждого маркера плотного ряда.
continue # Нечисловая амплитуда не имеет экранной координаты кривой.
column = -1 if stamp < left else width if stamp > right else min(width - 1, int((stamp - left) * scale)) # Соседи вынесены в два отдельных столбца за пределами видимой области.
point = (ordinal, stamp, value, segment) # Исходный порядок и номер участка позволяют вернуть экстремумы без ложных соединений через NaN.
pair = columns.get(column) # В каждом экранном столбце сохраняются только точные минимум и максимум.
if pair is None: # Первый конечный отсчёт одновременно задаёт обе роли нового столбца.
columns[column] = [point, point] # Два совпадающих индекса позднее объединяются в одну вершину.
else: # Дальнейшие измерения меняют только действительно достигнутые экстремумы.
if value < pair[0][2]: # Строгое сравнение сохраняет первое вхождение равного минимума.
pair[0] = point # Одиночный отрицательный выброс остаётся в экранной огибающей.
if value > pair[1][2]: # Строгое сравнение сохраняет первое вхождение равного максимума.
pair[1] = point # Одиночный положительный выброс также остаётся видимым при любом сжатии.
if left <= stamp <= right: # Внешние соседи не подменяют начало и конец собственно видимых измерений.
first_visible = point if first_visible is None else first_visible # Первый конечный видимый отсчёт сохраняется один раз независимо от числа столбцов.
last_visible = point # Последний конечный видимый отсчёт обновляется до правого края окна.
selected = {point[0]: point for pair in columns.values() for point in pair} # Уникальные исходные индексы устраняют совпадение ролей без изменения времени или амплитуды.
for point in (first_visible, last_visible): # Всего две дополнительные конечные точки сохраняют соединения на краях видимого окна.
if point is not None: # Полностью нечисловое окно не имеет конечных краевых измерений.
selected[point[0]] = point # Повторное добавление совпавшего экстремума не увеличивает экранный бюджет.
previous_segment = 0 # Нулевой участок соответствует началу исходной последовательности до первого NaN.
for ordinal in sorted(selected): # Возвращаем выбранные точки в исходном порядке, включая совпадающие метки времени.
_, stamp, value, current_segment = selected[ordinal] # Экранная вершина сохраняет исходные координаты и номер непрерывного участка.
if current_segment != previous_segment: # Различные номера подтверждают хотя бы один NaN между выбранными конечными отсчётами.
yield None # Маркер разрыва запрещает рисовать линию через отсутствующие измерения.
yield stamp, value # Амплитуда и время возвращаются точно, без усреднения и сдвига коротких пиков.
previous_segment = current_segment # Следующая вершина сравнивается с участком предыдущего выбранного измерения.
if seen and (not selected or previous_segment != segment): # Сохраняем полностью нечисловое окно и разрыв после последней конечной точки.
yield None # Длинная последовательность хвостовых пропусков ограничивается одним экранным маркером.
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)))