Добавить очереди операций PBUS/SBUS, общую консоль, настройки Candle и SLCAN, декодеры DSView и обработку плотных графиков. Обновить протокол Бальзам и проверки общих компонентов.
152 lines
17 KiB
Python
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)))
|