Добавить очереди операций PBUS/SBUS, общую консоль, настройки Candle и SLCAN, декодеры DSView и обработку плотных графиков. Обновить протокол Бальзам и проверки общих компонентов.
129 lines
9.5 KiB
Python
129 lines
9.5 KiB
Python
"""Software oscilloscope capture and sample measurements (timestamps in ms)."""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from itertools import islice # Блоки по 1024 элемента ограничивают задержку отмены длинных измерений без изменения порядка суммирования.
|
||
from set_devices.plot_ticks import tick_precision # Общая точность подписей позволяет различать малые деления времени на фоне большого абсолютного смещения.
|
||
|
||
|
||
def time_label(milliseconds, step=None): # Необязательный шаг в миллисекундах повышает точность только для подписей шкалы; остальные вызовы сохраняют прежний вид.
|
||
"""Readable time without rounding nanosecond divisions to zero."""
|
||
magnitude = abs(milliseconds)
|
||
precision = tick_precision(milliseconds, step, 6) if step is not None else 6 # Шесть значащих цифр остаются стандартом; малый шаг требует дополнительных разрядов до точности double.
|
||
if magnitude < .001:
|
||
return f"{milliseconds * 1e6:.{precision}g} нс" # В миллисекунде 10^6 наносекунд; выбранная точность сохраняет различие соседних малых делений.
|
||
if magnitude < 1:
|
||
return f"{milliseconds * 1000:.{precision}g} мкс" # В миллисекунде 1000 микросекунд; шаг и координата масштабируются одинаково, поэтому число значащих цифр не меняется.
|
||
if magnitude < 1000:
|
||
return f"{milliseconds:.{precision}g} мс" # Средние интервалы остаются в исходных миллисекундах с точностью, достаточной для текущего шага шкалы.
|
||
return f"{milliseconds / 1000:.{precision}g} с" # Деление на 1000 переводит миллисекунды в секунды без потери нужных значащих цифр.
|
||
|
||
|
||
def _measurement_items(items, check_cancelled): # Добавляем необязательную отмену к любому проходу статистики, сохраняя все исходные элементы и их порядок.
|
||
if check_cancelled is None: # Обычные вызовы измерений сохраняют прежний контракт без дополнительных проверок отмены.
|
||
yield from items # Передаём исходную последовательность без копирования всего ряда или численных преобразований.
|
||
return # Не создаём блоки для синхронных измерений, которым не передано средство отмены.
|
||
source = iter(items) # Один итератор обеспечивает последовательный обход без повторов на границах блоков.
|
||
while True: # Продолжаем обработку до исчерпания исходной последовательности или исключения отмены.
|
||
check_cancelled() # Новый жест навигации прерывает даже этап среднего, СКЗ или поиска экстремумов.
|
||
block = tuple(islice(source, 1024)) # Не более 1024 элементов между проверками удерживают задержку отмены малой на многомиллионной записи.
|
||
if not block: # Пустой блок означает, что все исходные отсчёты уже переданы численной операции.
|
||
return # Последняя проверка отмены выполнена до завершения прохода.
|
||
yield from block # Сохраняем порядок элементов для прежней точности math.fsum и поиска пересечений.
|
||
|
||
|
||
def measurements(points, check_cancelled=None): # Необязательный обработчик отмены расширяет API, не меняя существующие вызовы с одним аргументом.
|
||
values = [value for _, value in _measurement_items(points, check_cancelled) if math.isfinite(value)] # Проверяем отмену и при отборе конечных амплитуд, включая полностью нечисловой ряд.
|
||
if not values:
|
||
return None
|
||
low = min(_measurement_items(values, check_cancelled)) # Точный минимум вычисляется по всем конечным значениям с ограниченной задержкой отмены.
|
||
high = max(_measurement_items(values, check_cancelled)) # Точный максимум сохраняет прежнюю семантику, в том числе порядок равных значений.
|
||
mean = math.fsum(value / len(values) for value in _measurement_items(values, check_cancelled)) # Отмена проверяется внутри суммирования; порядок и нормировка слагаемых остаются прежними.
|
||
scale = max(abs(low), abs(high))
|
||
rms = scale * math.sqrt(math.fsum((value / scale) ** 2 for value in _measurement_items(values, check_cancelled)) / len(values)) if scale else 0. # Прерываем длинный расчёт СКЗ без потери прежней защиты от переполнения.
|
||
# Hysteresis rejects small excursions around the midpoint. Frequency is
|
||
# only reported after two complete rising crossings, never from one edge.
|
||
middle = (low + high) / 2
|
||
hysteresis = (high - low) * .05
|
||
armed = False
|
||
crossings = []
|
||
previous = None
|
||
if high > low:
|
||
for stamp, value in _measurement_items(points, check_cancelled): # Проверяем отмену и при полном проходе поиска частоты, сохраняя исходную гистерезисную обработку пропусков.
|
||
if value < middle - hysteresis:
|
||
armed = True
|
||
if armed and previous and previous[1] < middle <= value and stamp > previous[0]:
|
||
t0, v0 = previous
|
||
crossings.append(t0 + (stamp - t0) * (middle - v0) / (value - v0))
|
||
armed = False
|
||
previous = (stamp, value)
|
||
period = ((crossings[-1] - crossings[0]) / (len(crossings) - 1)
|
||
if len(crossings) >= 2 else None)
|
||
return dict(minimum=low, maximum=high, peak_to_peak=high - low, mean=mean,
|
||
rms=rms, frequency=1000 / period if period and period > 0 else None)
|
||
|
||
|
||
class SingleCapture:
|
||
"""Edge trigger with 20% pretrigger history and 80% posttrigger samples.
|
||
|
||
Samples are display-scaled; storage/recording remains owned by the caller.
|
||
Capture is bounded by the caller's point capacity for each channel.
|
||
"""
|
||
def __init__(self):
|
||
self.cancel()
|
||
|
||
def cancel(self):
|
||
self.armed = False
|
||
self.trigger_time = None
|
||
self.previous = None
|
||
self.data = {}
|
||
self.truncated = False
|
||
|
||
def arm(self, channel, level, falling, span_ms, capacity):
|
||
self.cancel()
|
||
self.channel = channel
|
||
self.level = level
|
||
self.falling = falling
|
||
self.span_ms = max(.0001, min(60_000., span_ms))
|
||
self.capacity = capacity
|
||
self.armed = True
|
||
|
||
def feed(self, stamp, sample, history):
|
||
"""Return a complete frame once; history must include this sample."""
|
||
if not self.armed:
|
||
return None
|
||
value = sample.get(self.channel)
|
||
if self.trigger_time is None:
|
||
previous = self.previous
|
||
if value is not None:
|
||
self.previous = (stamp, value)
|
||
if previous is None or value is None or stamp <= previous[0]:
|
||
return None
|
||
crossed = (previous[1] > self.level >= value if self.falling
|
||
else previous[1] < self.level <= value)
|
||
if not crossed:
|
||
return None
|
||
t0, v0 = previous
|
||
self.trigger_time = t0 + (stamp - t0) * (self.level - v0) / (value - v0)
|
||
self.left = self.trigger_time - .2 * self.span_ms
|
||
self.right = self.trigger_time + .8 * self.span_ms
|
||
if callable(history):
|
||
history = history()
|
||
source = history.get(self.channel, ())
|
||
self.truncated = not source or source[0][0] - self.trigger_time > -.2 * self.span_ms
|
||
self.data = {key: [(t, v) for t, v in points
|
||
if -.2 * self.span_ms <= t - self.trigger_time <= .8 * self.span_ms]
|
||
for key, points in history.items()}
|
||
else:
|
||
for key, value in sample.items():
|
||
if -.2 * self.span_ms <= stamp - self.trigger_time <= .8 * self.span_ms:
|
||
points = self.data.setdefault(key, [])
|
||
if len(points) < self.capacity:
|
||
points.append((stamp, value))
|
||
else:
|
||
self.truncated = True
|
||
if stamp - self.trigger_time >= .8 * self.span_ms:
|
||
self.armed = False
|
||
return self.data
|
||
return None
|