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

129 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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