import math # Проверяем конечность измерений, чтобы пропуски не соединялись линиями и не участвовали в экстремумах. from array import array # Храним индексы в компактных массивах вместо отдельных объектов Python для каждого узла дерева. from bisect import bisect_left, bisect_right # Находим границы пиксельных столбцов двоичным поиском по упорядоченным меткам времени. class EnvelopeIndex: # Индекс неизменяемого ряда позволяет выбирать точные экстремумы без повторного обхода миллионов отсчётов. BLOCK_SIZE = 32 # Лист дерева содержит 32 отсчёта: крайние неполные блоки малы, а индекс занимает около 2,5 байта на отсчёт. EMPTY = (-1, -1, -1, -1, 0) # Первые четыре поля обозначают индексы первого, минимального, максимального и последнего отсчёта; пятое считает пропуски. def __init__(self, values, checkpoint=None): # Необязательная контрольная точка сохраняет отзывчивость Qt при первоначальном построении большого индекса. self.values = values # Сохраняем исходные значения по ссылке; сам индекс не сокращает и не изменяет измерения. self.count = len(values) # Запоминаем размер снимка для проверки пригодности индекса после очистки графика. blocks = (self.count + self.BLOCK_SIZE - 1) // self.BLOCK_SIZE # Округление вверх включает неполный последний блок записи. self.leaves = 1 # Для обычного двоичного дерева число листьев должно быть степенью двух. while self.leaves < blocks: # Добавляем пустые листья только до ближайшей степени двух, не удваивая размер без необходимости. self.leaves *= 2 # Два дочерних узла соответствуют объединённому диапазону родителя. self.nodes = tuple(array('q', [field]) * (2 * self.leaves) for field in self.EMPTY) # Пять знаковых 64-битных массивов содержат индексы, включая признак отсутствия -1. for block in range(blocks): # Полный обход исходных значений нужен только при первоначальном построении индекса. if checkpoint is not None and block % 32 == 0: # Тридцать два листа по 32 отсчёта дают контроль примерно через 1024 исходных значения. checkpoint() # Отпускаем GIL и проверяем отмену между ограниченными порциями подготовки экстремумов. start = block * self.BLOCK_SIZE # Переводим номер блока в начальный индекс исходного отсчёта. self._store(self.leaves + block, self._scan(start, min(self.count, start + self.BLOCK_SIZE))) # Сводка листа сохраняет точные экстремумы и число нечисловых отсчётов. for node in range(self.leaves - 1, 0, -1): # Строим родителей снизу вверх, когда сводки обоих детей уже готовы. if checkpoint is not None and node % 1024 == 0: # Объединение большого дерева также делится на порции по 1024 узла. checkpoint() # Не допускаем непрерывного удержания GIL после завершения чтения исходных измерений. self._store(node, self._merge(self._load(2 * node), self._load(2 * node + 1))) # Родитель объединяет соседние диапазоны с сохранением временного порядка. def _store(self, node, summary): # Записываем одну сводку в параллельные компактные массивы дерева. for column, field in zip(self.nodes, summary): # Каждое поле имеет одинаковый номер узла во всех пяти массивах. column[node] = field # Индексы ссылаются на исходный ряд, поэтому значения Y не дублируются в памяти. def _load(self, node): # Читаем сводку готового узла без обращения ко всем его исходным отсчётам. first, low, high, last, gaps = self.nodes # Разбираем пять массивов один раз, исключая создание генератора при каждом посещении узла дерева. return first[node], low[node], high[node], last[node], gaps[node] # Возвращаем те же пять полей напрямую; тысячи запросов кадра не создают промежуточных итераторов. def _scan(self, start, stop): # Сканируем полуинтервал только при построении листа либо на неполных краях запроса. first = low = high = last = -1 # Отрицательный индекс означает, что конечное значение ещё не найдено. gaps = 0 # Подсчёт пропусков позволяет проверять разрыв линии между выбранными экстремумами. values, isfinite = self.values, math.isfinite # Локальные ссылки исключают повторный поиск атрибутов для каждого отсчёта неполного края блока. low_value = high_value = 0. # Значения обоих экстремумов устанавливаются первым конечным отсчётом; ноль не участвует в сравнении до этого момента. for index in range(start, stop): # Полуинтервал исключает stop и совпадает с правилами срезов Python. value = values[index] # Читаем исходное измерение ровно один раз, сохраняя значение для обоих сравнений экстремумов. if not isfinite(value): # NaN и бесконечности должны разрывать путь, а не менять масштаб или экстремумы. gaps += 1 # Учитываем пропуск даже в диапазоне, где совсем нет конечных значений. continue # Исключаем нечисловой отсчёт из поиска первого, последнего, минимального и максимального значений. if first < 0: # Первый конечный отсчёт одновременно задаёт оба начальных экстремума. first = low = high = index # Все три индекса указывают на одно измерение до появления следующих точек. low_value = high_value = value # Сохраняем амплитуды начальных экстремумов, чтобы следующие отсчёты не читали их повторно из большого ряда. else: # Для остальных отсчётов обновляем только реально изменившиеся экстремумы. if value < low_value: # Строгое сравнение с сохранённой амплитудой сохраняет первый по времени минимум при равных значениях. low = index # Запоминаем точный индекс минимального исходного значения. low_value = value # Обновляем амплитуду вместе с индексом, исключая отдельное чтение минимума на следующей итерации. if value > high_value: # Первый по времени максимум также остаётся стабильным при равенстве значений. high = index # Запоминаем точный индекс максимального исходного значения. high_value = value # Сохраняем амплитуду нового максимума для дальнейших сравнений без обращения к исходному контейнеру. last = index # Последний конечный отсчёт нужен для правильного соединения соседних пиксельных столбцов. return first, low, high, last, gaps # Сводка содержит только индексы и счётчик; измерения остаются в исходном ряду. def _merge(self, before, after): # Объединяем сводки соседних диапазонов, расположенных в исходном временном порядке. gaps = before[4] + after[4] # Число пропусков аддитивно и не зависит от расположения экстремумов. if before[0] < 0: # Левый диапазон может быть пустым либо полностью состоять из пропусков. return (*after[:4], gaps) # Сохраняем конечные точки правого диапазона и пропуски обоих диапазонов. if after[0] < 0: # Пустой правый диапазон не меняет конечные точки левого диапазона. return (*before[:4], gaps) # Добавляем только число пропусков справа. low = after[1] if self.values[after[1]] < self.values[before[1]] else before[1] # При равных минимумах сохраняем более ранний отсчёт. high = after[2] if self.values[after[2]] > self.values[before[2]] else before[2] # При равных максимумах также сохраняем более ранний отсчёт. return before[0], low, high, after[3], gaps # Крайние точки берём из внешних границ объединённого диапазона. def between(self, start, stop): # Возвращаем точную сводку произвольного диапазона индексов без полного сканирования ряда. start, stop = max(0, start), min(self.count, stop) # Ограничиваем запрос существующими измерениями снимка. if stop <= start: # Пустое или обратное окно не содержит ни точек, ни пропусков. return self.EMPTY # Единый пустой результат упрощает объединение диапазонов на границах записи. if not self.nodes[4][1] and isinstance(self.values, (list, tuple, array)): # Нулевой счётчик пропусков корня доказывает конечность всего неизменяемого ряда; обычные контейнеры допускают короткие срезы краёв. return self._finite_between(start, stop) # Плотные записи без NaN обходятся без пяти-польных сводок и проверок конечности на каждом кадре. first_block = (start + self.BLOCK_SIZE - 1) // self.BLOCK_SIZE # Находим первый полностью включённый блок после левого неполного края. last_block = stop // self.BLOCK_SIZE # Этот блок уже не входит целиком в запрос и задаёт начало правого края. if first_block >= last_block: # Короткий запрос дешевле прочитать напрямую, чем обходить дерево. return self._scan(start, stop) # Здесь читается не более двух неполных блоков исходных отсчётов. before = self._scan(start, first_block * self.BLOCK_SIZE) # Получаем сводку левого неполного края, максимум из 31 отсчёта. after = self._scan(last_block * self.BLOCK_SIZE, stop) # Получаем сводку правого неполного края, также максимум из 31 отсчёта. left, right = self.leaves + first_block, self.leaves + last_block # Переводим границы полных блоков в номера листьев дерева. while left < right: # За логарифмическое число шагов выбираем узлы, полностью покрывающие внутренний диапазон. if left % 2: # Правый ребёнок слева включается отдельно, чтобы не захватить отсчёты перед запросом. before = self._merge(before, self._load(left)) # Добавляем очередной диапазон после уже выбранной левой части. left += 1 # Переходим к следующему соседнему узлу перед подъёмом к родителю. if right % 2: # Правая исключённая граница позволяет включить её левого соседа отдельно. right -= 1 # Перемещаем границу к последнему целиком включённому узлу текущего уровня. after = self._merge(self._load(right), after) # Добавляем узел перед уже выбранной правой частью, сохраняя порядок. left //= 2 # Поднимаем левую границу на уровень объединённых диапазонов. right //= 2 # Правая граница поднимается на тот же уровень дерева. return self._merge(before, after) # Соединяем выбранные части в одну точную сводку запрошенного полуинтервала. def _finite_between(self, start, stop): # Быстрый запрос применяется только после проверки отсутствия NaN и бесконечностей во всём исходном ряду. values = self.values # Сохраняем локальную ссылку на полные исходные амплитуды, не создавая их копию. first_block = (start + self.BLOCK_SIZE - 1) // self.BLOCK_SIZE # Левый неполный край заканчивается перед первым целиком включённым блоком. last_block = stop // self.BLOCK_SIZE # Последний целый блок предшествует правому неполному краю запроса. if first_block >= last_block: # Короткий диапазон занимает не более двух блоков по 32 отсчёта и не требует обхода дерева. chunk = values[start:stop] # Временный срез содержит максимум 63 исходных амплитуды, независимо от полного размера лога. return start, start + chunk.index(min(chunk)), start + chunk.index(max(chunk)), stop - 1, 0 # Встроенные операции находят точные первые экстремумы на C; первый, последний и нулевые пропуски уже известны. low = high = start # Любой непустой конечный диапазон имеет допустимое начальное измерение на левой границе. low_value = high_value = values[start] # Амплитуды экстремумов храним локально, чтобы не перечитывать их из большого массива после каждого узла. for begin, end in ((start, first_block * self.BLOCK_SIZE), (last_block * self.BLOCK_SIZE, stop)): # Сканируем только два неполных края, каждый не длиннее 31 исходного отсчёта. if begin < end: # Совпадение с границей блока исключает создание пустого среза и вызов min/max без элементов. chunk = values[begin:end] # Короткий временный срез позволяет выполнить поиск экстремумов встроенными операциями C. minimum, maximum = min(chunk), max(chunk) # Исходные конечные амплитуды сравниваются без повторной проверки math.isfinite. if minimum < low_value: # Края идут слева направо, поэтому при равенстве оставляем уже найденный более ранний минимум. low, low_value = begin + chunk.index(minimum), minimum # index возвращает первое вхождение точного минимума внутри неполного края. if maximum > high_value: # При равенстве максимумов также сохраняем более раннее измерение. high, high_value = begin + chunk.index(maximum), maximum # Индекс исходной амплитуды вычисляется смещением от начала короткого среза. lows, highs = self.nodes[1], self.nodes[2] # Для конечного ряда нужны только индексы минимумов и максимумов; остальные три массива не читаются. left, right = self.leaves + first_block, self.leaves + last_block # Переводим полуинтервал целых блоков в номера листьев компактного дерева. while left < right: # Как и общий алгоритм, выбираем лишь логарифмическое число готовых сводок диапазона. if left % 2: # Отдельный правый ребёнок слева входит в запрос целиком, а его сосед остаётся за границей. candidate = lows[left] # Готовый индекс минимума узла ссылается непосредственно на исходный ряд. value = values[candidate] # Читаем только амплитуду найденного в узле минимума. if value < low_value or (value == low_value and candidate < low): # Узлы посещаются не строго слева направо, поэтому равные значения сравниваем и по исходному индексу. low, low_value = candidate, value # Обновляем точное первое вхождение минимума без создания промежуточной сводки. candidate = highs[left] # Второй готовый индекс узла соответствует точному исходному максимуму. value = values[candidate] # Амплитуда максимума также читается один раз на выбранный узел. if value > high_value or (value == high_value and candidate < high): # Равные максимумы должны сохранять прежний выбор самого раннего отсчёта. high, high_value = candidate, value # Обновляем максимум и его амплитуду вместе для следующего сравнения. left += 1 # Исключаем уже обработанный узел перед подъёмом обеих границ на следующий уровень. if right % 2: # Левый сосед исключённой правой границы входит в диапазон полностью. right -= 1 # Получаем номер последнего целиком включённого узла текущего уровня дерева. candidate = lows[right] # Читаем индекс готового минимума правого узла без распаковки пяти полей сводки. value = values[candidate] # Только выбранный минимум требует обращения к исходной амплитуде. if value < low_value or (value == low_value and candidate < low): # Порядок обхода не влияет на первый по времени минимум при равенстве амплитуд. low, low_value = candidate, value # Сохраняем лучший исходный минимум для объединённого запроса. candidate = highs[right] # Индекс максимума берём из второго массива готовых экстремумов. value = values[candidate] # Читаем точную амплитуду исходного максимума правого узла. if value > high_value or (value == high_value and candidate < high): # При одинаковых амплитудах выбираем наименьший исходный индекс. high, high_value = candidate, value # Объединяем максимум напрямую, исключая вызовы _load и _merge на каждом узле. left //= 2 # Переходим от листьев или текущих узлов к покрывающим их родителям. right //= 2 # Правая исключённая граница поднимается на тот же уровень дерева. return start, low, high, stop - 1, 0 # Все точки конечны, поэтому внешние отсчёты и отсутствие пропусков не требуют отдельного вычисления. def points(self, timestamps, left, right, width, *, extrema_only=False): # Обычная огибающая сохраняет четыре роли на столбец, а отдельный экранный режим оставляет только точные минимум и максимум. count = min(len(timestamps), self.count) # Общий размер защищает от несовпадающих длин координат и значений. start, stop = bisect_left(timestamps, left, 0, count), bisect_right(timestamps, right, 0, count) # Оба края видимого временного окна включаются полностью. ranges = [] # Список содержит только диапазоны пикселей и по одному соседнему отсчёту для отсечения на краях. if start: # Сосед слева сохраняет линию, пересекающую левую границу без отсчёта непосредственно внутри окна. ranges.append((start - 1, start)) # Только один внешний отсчёт нужен для правильного геометрического отсечения. if right <= left: # Совпадающие границы X могут содержать много отсчётов с одной меткой времени, но не допускают деления на ширину окна. if stop > start: # Пустое окно не создаёт искусственный диапазон измерений. ranges.append((start, stop)) # Экстремумы всех совпадающих меток времени ограничены четырьмя точками одного столбца. else: # Любое окно делим по экранным координатам, чтобы неравномерные и повторяющиеся метки также соблюдали бюджет на столбец. edge = start # Первый пиксель начинается с первого отсчёта внутри видимого окна. for column in range(width): # Число итераций зависит только от ширины изображения в физических пикселях. boundary = left + (right - left) * ((column + 1) / width) # Правая граница столбца задана в единицах исходной оси X. end = stop if column == width - 1 else bisect_left(timestamps, boundary, edge, stop) # Последний столбец включает отсчёты точно на правой границе окна. if end > edge: # Пустые пиксели не создают искусственных измерений. ranges.append((edge, end)) # Запоминаем исходный диапазон для выбора первого, минимального, максимального и последнего отсчётов. edge = end # Следующий столбец продолжает ряд без потерь и повторного включения границы. if stop < count: # Сосед справа сохраняет линию, пересекающую правую границу окна. ranges.append((stop, stop + 1)) # Внешний сосед добавляет не более одной конечной точки к пиксельному бюджету. has_gaps = bool(self.nodes[4][1]) # Нулевой счётчик корня доказывает отсутствие пропусков во всём ряду и исключает лишние запросы между компактными экстремумами. previous = None # Индекс предыдущей выбранной конечной точки нужен для проверки разрыва между столбцами. for begin, end in ranges: # Обрабатываем только экранные диапазоны и двух возможных внешних соседей. summary = self.between(begin, end) # Получаем точные экстремумы через индекс, читая лишь неполные края блоков. roles = summary[1:3] if extrema_only else summary[:4] # Новый экранный режим уменьшает число вершин, не изменяя исходные экстремумы или прежний публичный результат. if extrema_only and begin == start: # Первый видимый столбец сохраняет также начало линии, чтобы она правильно пересекала левый край окна. roles += (summary[0],) # Дополнительная роль нужна только одному столбцу, а не каждому пикселю. if extrema_only and end == stop: # Последний видимый столбец сохраняет конец линии для правильного пересечения правого края. roles += (summary[3],) # Два внешних конца увеличивают общий бюджет только на две конечные точки. selected = sorted({index for index in roles if index >= 0}) # Убираем совпадающие роли и восстанавливаем исходный временной порядок выбранных экстремумов. if not selected: # Полностью нечисловой диапазон разрывает линию один раз независимо от числа NaN внутри пикселя. yield None # Маркер разрыва не является измерением и обрабатывается обоими движками отрисовки. previous = None # Следующая конечная точка начинает новый путь. continue # В пустом пикселе нет конечных экстремумов для отрисовки. for index in selected: # На один непустой пиксель приходится не более четырёх исходных измерений. if has_gaps and previous is not None and previous + 1 < index and (summary[4] or previous < begin) and self.between(previous + 1, index)[4]: # Проверяем разрывы только при наличии NaN в записи; соседние индексы и доказанно конечные ряды не требуют дополнительных запросов дерева. yield None # Разрыв сохраняет пропуски между выбранными экстремумами без повторного запроса того же диапазона дерева. yield timestamps[index], self.values[index] # Возвращаем исходные координаты и значения без усреднения или изменения амплитуды. previous = index # Запоминаем конец текущего пути для следующей выбранной точки.