From a1b283545765d3177bacb27328f1b92d84d389de Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 6 Nov 2026 23:48:41 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=B3=20=D1=81=D0=B8=D0=B3=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B8=20=D0=BF=D0=BB=D0=B0=D0=B2=D0=BD=D1=83?= =?UTF-8?q?=D1=8E=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D1=8E=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавить общую модель двухканальных сигналов и анимированных XY-сцен для предпросмотра, экспорта CSV и подготовки синхронных каналов. Построить округлый силуэт кота кривыми Безье, добавить овальные глаза, загнутый хвост и плавную походку: 32 позы по 125 точек, всего 4000 пар. Проверка: 71 тест каталога, диапазонов, CSV и управления генератором. --- python/set_devices/waveform_examples.py | 242 ++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 python/set_devices/waveform_examples.py diff --git a/python/set_devices/waveform_examples.py b/python/set_devices/waveform_examples.py new file mode 100644 index 0000000..3c55adc --- /dev/null +++ b/python/set_devices/waveform_examples.py @@ -0,0 +1,242 @@ +"""Двадцать синхронных заданий для двухканального ЦАП и векторного экрана XY.""" # Общая математика не зависит от Qt и используется предпросмотром и готовыми CSV. +import math # Аналитические траектории и несущие вычисляются в нормированных координатах. +from dataclasses import dataclass # Неизменяемый результат связывает описание, контуры и общую временную сетку. + +SAMPLE_RATE = 40_000 # 40 кГц делит без остатка TIM6 72 МГц F407 и 160 МГц G474. +SAMPLE_COUNT = 1000 # Тысяча пар укладывается в аппаратный предел 4096 и даёт повторение 40 Гц при 40 кГц. +CAT_SAMPLE_RATE = 4_000 # 4000 пар/с воспроизводят 32 позы по 125 точек за одну секунду: 32 кадра/с и один шаговый цикл в секунду. +ANIMATION_SAMPLE_RATE = 4_000 # Полный геометрический цикл из 4000 пар длится одну секунду и использует точный делитель TIM6 обоих MCU. +ANIMATION_MODES = (("auto", "Движение фигуры"), ("twist", "Скручивание"), ("rotate", "Вращение"), ("pulse", "Пульсация"), ("none", "Статично")) # Параметр выбирает изменение координат всей фигуры, а не способ подсветки обходящего её луча. +WALKING_CAT = ("walking_cat", "Кот идёт · усы, лапы и хвост", "Полный цикл походки: 32 плавные позы, округлые щёки и спина, овальные глаза, четыре лапы, усы и изогнутый хвост.") # Дополнительный анимированный пример не заменяет десять основных XY-фигур. +BOAT_ON_WAVES = ("boat_waves", "Кораблик на волнах", "Кораблик, мачта, паруса и флажок неподвижны; два ряда волн движутся вправо под корпусом.") # Специальная сцена сохраняет неизменную геометрию кораблика во всех позах движения воды. +XY_EXTRA_EXAMPLES = (WALKING_CAT, BOAT_ON_WAVES) # Общий список дополнительных сцен синхронизирует каталог UI, готовые CSV и математическую модель. +DUAL_EXAMPLES = ( # Два канала используют общий период и воспроизводятся синхронно через PA4 и PA5. + ("quadrature", "Синус и косинус · 90°", "Одинаковая частота, квадратурный сдвиг 90°; в XY получается окружность."), # Базовая проверка амплитуды, полярности и синхронности каналов. + ("phase_120", "Два синуса · 120°", "Два синуса со сдвигом 120° для проверки фазовых измерений."), # Фазовый сдвиг воспроизводится без изменения временной сетки. + ("harmonic_1_2", "Основная частота и вторая гармоника", "CH1 содержит один период, CH2 — два периода за общий кадр."), # Целое отношение частот замыкает оба сигнала на границе таблицы. + ("beats_5_6", "Биения · 5:6", "Пять и шесть колебаний за кадр; сложение каналов показывает биения."), # Разность частот равна частоте повторения общего кадра. + ("triangle_sine", "Треугольник и синус", "Два периода треугольника и синуса для сравнения формы и гармоник."), # Обе формы имеют одинаковые амплитуду и смещение. + ("square_quadrature", "Два меандра · 90°", "Квадратурные прямоугольные сигналы с заполнением 50 %."), # Смещение на четверть периода удобно для проверки квадратурного счётчика. + ("pwm_25_75", "Импульсы · заполнение 25 / 75 %", "Два синхронных импульсных сигнала с разной длительностью высокого уровня."), # Общий старт периода позволяет сравнивать заполнение на осциллографе. + ("amplitude_modulation", "АМ-сигнал и огибающая", "CH1 — несущая с восемью колебаниями, CH2 — её медленная огибающая."), # Глубина модуляции не выводит сигнал за диапазон ЦАП. + ("chirp_iq", "ЛЧМ · квадратура I/Q", "Частота растёт от 2 до 14 колебаний на кадр; второй канал сдвинут на 90°."), # Интеграл линейной частоты даёт восемь полных оборотов фазы за кадр. + ("ring_down", "Затухающие колебания · два канала", "Восемь квадратурных колебаний с экспоненциальным затуханием; пакет повторяется."), # Переход через границу кадра является началом следующего пакета. +) # Стабильные ключи совпадают с именами готовых CSV. +XY_EXAMPLES = ( # Контуры рассчитаны для одинакового масштаба X и Y и входной связи DC. + ("smiley", "Улыбка", "Лицо, глаза и улыбка; быстрые переходы между четырьмя контурами."), # Первый пример сохраняет прежний тест генератора. + ("circle", "Окружность", "Один непрерывный круг — проверка квадратуры и масштаба каналов."), # Простейшая замкнутая траектория без переходов между деталями. + ("heart", "Сердце", "Параметрическое сердце с плавным замкнутым контуром."), # Классическая сумма гармоник формирует узнаваемую фигуру. + ("star", "Пятиконечная звезда", "Непрерывный контур звезды с пятью внешними и пятью внутренними вершинами."), # Луч проходит все стороны в порядке обхода контура. + ("rose", "Лазерный цветок · 5 лепестков", "Полярная роза с пятью лепестками и общим центром."), # Повторяющиеся дуги напоминают узоры векторного лазерного шоу. + ("lissajous", "Лиссажу · 3:2", "Замкнутая сетка из синусоид с отношением частот 3:2."), # Целые множители обеспечивают непрерывное периодическое замыкание. + ("spirograph", "Спирограф · орбиты", "Гипотрохоида с восемью лепестками и переплетающимися орбитами."), # Разность основной и обратной гармоник создаёт тонкий симметричный узор. + ("butterfly", "Бабочка", "Аналитическая кривая бабочки: симметричные крылья и волнистый край."), # Несколько оборотов параметра прорисовывают все детали замкнутой фигуры. + ("saturn", "Сатурн", "Круглая планета и наклонное эллиптическое кольцо."), # Два контура сохраняются отдельно, чтобы предпросмотр показывал реальный переход. + ("laser_tunnel", "Лазерный туннель", "Внешняя рамка неподвижна, внутренние скручиваются и раскручиваются в замкнутом цикле."), # Движение меняет относительные углы рамок, сохраняя центр и размер внешнего контура. +) # Ровно десять XY-примеров доступны в интерфейсе и на диске. + +@dataclass(frozen=True) # Подготовленный кадр нельзя незаметно изменить после его выбора или экспорта. +class ExampleData: # Один результат содержит всё необходимое для просмотра и подготовки обоих выходов. + key: str # Постоянный идентификатор используется в CSV и каталоге примеров. + kind: str # Значение dual или xy определяет временной либо геометрический предпросмотр. + title: str # Русское название отображается в окне выбора и документации. + description: str # Описание объясняет форму, отношение частот и особенности обхода. + rate: int # Общая частота обоих каналов измеряется в парах отсчётов в секунду. + samples: tuple # Каждая строка содержит время в миллисекундах, X/CH1 и Y/CH2 в вольтах. + strokes: tuple # Контуры XY сохраняют границы деталей для честного отображения переходов луча. + frames: tuple = () # Анимация хранит отдельные позы, тогда как аппаратная таблица последовательно содержит их все. + animation: str = "none" # Название фактического движения связывает предпросмотр, аппаратные кадры и метаданные экспортированного примера. + +def _curve(function, count): # Аналитическая траектория преобразуется в фиксированное число отсчётов одного периода. + return tuple(function(2 * math.pi * index / count) for index in range(count)) # Конечная точка периода не дублируется, замыкание происходит при повторении DMA-таблицы. + +def _polygon(vertices, count): # Равномерное движение вдоль ломаной не зависит от разной длины её сторон. + lengths = [math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(vertices, vertices[1:])] # Длины сторон измеряются в нормированных координатах фигуры. + total, segment, origin = sum(lengths), 0, 0.0 # Суммарный путь и начало текущей стороны задают положение движущегося луча. + result = [] # Координаты сохраняются в порядке обхода, без скачков между соседними сторонами. + for index in range(count): # Аппаратный кадр получает ровно запрошенное число координат. + distance = total * index / count # Равный шаг по длине пути удерживает примерно одинаковую яркость прямых участков. + while segment < len(lengths) - 1 and distance > origin + lengths[segment]: # Переходим к следующей стороне после достижения её начала. + origin += lengths[segment] # Пройденная длина предыдущей стороны исключается из локальной координаты. + segment += 1 # Последующая интерполяция использует следующую пару вершин. + fraction = (distance - origin) / lengths[segment] # Доля текущей стороны лежит между её началом и концом. + a, b = vertices[segment:segment + 2] # Две соседние вершины определяют единственный прямой участок. + result.append((a[0] + fraction * (b[0] - a[0]), a[1] + fraction * (b[1] - a[1]))) # Линейная интерполяция не добавляет закругления к углам геометрической фигуры. + return tuple(result) # Готовая последовательность подходит для периодического воспроизведения и предпросмотра. + +def smiley_strokes(vref): # Прежний рисунок сохраняет четыре контура и ровно 1000 синхронных отсчётов. + if not math.isfinite(vref) or not 0 < vref <= 3.6: # Предел опорного напряжения совпадает с контрактом 12-битного ЦАП. + raise ValueError("Vref должен быть в диапазоне 0…3,6 В") # Недопустимая шкала отвергается до расчёта таблицы. + contours = ((0, 0, .85, 0, 2 * math.pi, 500), (-.27, .25, .055, 0, 2 * math.pi, 80), (.27, .25, .055, 0, 2 * math.pi, 80), (0, .10, .50, 7 * math.pi / 6, 11 * math.pi / 6, 340)) # Лицо, два глаза и нижняя дуга улыбки сохраняют прежние размеры и порядок. + return tuple(tuple(((1 + cx + radius * math.cos(start + (stop - start) * i / (count - 1))) * vref / 2, (1 + cy + radius * math.sin(start + (stop - start) * i / (count - 1))) * vref / 2) for i in range(count)) for cx, cy, radius, start, stop, count in contours) # Обе конечные точки дуг включены, а отступ лица от шин питания составляет 7,5 % Vref. + +def _xy_strokes(key): # Нормированные траектории отделены от электрического масштаба и поворота рисунка. + if key == "smiley": # Совместимость старого теста важна для экспорта и настройки осциллографа. + return tuple(tuple((x - 1, y - 1) for x, y in stroke) for stroke in smiley_strokes(2.0)) # При Vref 2 В вычитание единицы восстанавливает исходные координаты вокруг нуля. + functions = { # Каждый аналитический контур укладывается в один непрерывный аппаратный период. + "circle": lambda a: (math.cos(a), math.sin(a)), # Одинаковые амплитуды синуса и косинуса дают точный круг. + "heart": lambda a: (16 * math.sin(a) ** 3, 13 * math.cos(a) - 5 * math.cos(2 * a) - 2 * math.cos(3 * a) - math.cos(4 * a)), # Классическая параметризация сердца использует безразмерные коэффициенты гармоник. + "rose": lambda a: (math.cos(5 * a) * math.cos(a), math.cos(5 * a) * math.sin(a)), # Полярный радиус cos(5a) создаёт пять симметричных лепестков. + "lissajous": lambda a: (math.sin(3 * a + math.pi / 2), math.sin(2 * a)), # Частоты 3 и 2 колебания за период формируют замкнутую фигуру Лиссажу. + "spirograph": lambda a: (.65 * math.cos(a) + .35 * math.cos(7 * a), .65 * math.sin(a) - .35 * math.sin(7 * a)), # Противоположное вращение седьмой гармоники создаёт восемь радиальных лепестков. + "butterfly": lambda a: (math.sin(a) * (math.exp(math.cos(a)) - 2 * math.cos(4 * a) - math.sin(a / 12) ** 5), math.cos(a) * (math.exp(math.cos(a)) - 2 * math.cos(4 * a) - math.sin(a / 12) ** 5)), # Полный период аналитической бабочки составляет 24π по параметру a. + } # Геометрическое масштабирование ниже оставляет запас до нуля и Vref при любом повороте. + if key in functions: # Большинство фигур формируются одним аналитическим контуром. + function = functions[key] # Выбор по ключу не зависит от перевода отображаемого названия. + strokes = (_curve((lambda a: function(12 * a)) if key == "butterfly" else function, SAMPLE_COUNT),) # Двенадцать оборотов замыкают полный период бабочки; остальные кривые обходятся за 2π. + elif key == "star": # Чередование двух радиусов формирует выпуклые и внутренние вершины звезды. + vertices = tuple(((1 if i % 2 == 0 else .42) * math.cos(math.pi / 2 + i * math.pi / 5), (1 if i % 2 == 0 else .42) * math.sin(math.pi / 2 + i * math.pi / 5)) for i in range(10)) # Внешние вершины смотрят вверх; внутренний радиус равен 42 % внешнего. + strokes = (_polygon(vertices + vertices[:1], SAMPLE_COUNT),) # Дополнительная первая вершина задаёт последнюю замыкающую сторону. + elif key == "saturn": # Планета и кольцо требуют двух отдельных обходов. + strokes = (_curve(lambda a: (.54 * math.cos(a), .54 * math.sin(a)), 500), _curve(lambda a: (math.cos(a), .28 * math.sin(a) + .30 * math.cos(a)), 500)) # Равные бюджеты отсчётов дают круг и наклонное эллиптическое кольцо. + elif key == "laser_tunnel": # Четыре вложенных рамки создают перспективную композицию. + strokes = tuple(_polygon(tuple((radius * math.cos(math.pi / 4 + i * math.pi / 2 + turn), radius * math.sin(math.pi / 4 + i * math.pi / 2 + turn)) for i in range(5)), 250) for radius, turn in ((1, 0), (.72, .12), (.45, .24), (.20, .36))) # По 250 отсчётов на рамку сохраняют общий размер 1000 пар и постепенный поворот вложенных квадратов. + else: # Неизвестный ключ не должен незаметно подменяться случайной фигурой. + raise ValueError("Неизвестный XY-пример: " + key) # Ошибка данных каталога выявляется до подготовки аппаратного задания. + points = [point for stroke in strokes for point in stroke] # Общий центр и масштаб вычисляются по всем деталям рисунка. + cx = (min(x for x, _ in points) + max(x for x, _ in points)) / 2 # Центр ограничивающего прямоугольника убирает постоянное смещение сложной фигуры. + cy = (min(y for _, y in points) + max(y for _, y in points)) / 2 # Вертикальное центрирование сохраняет запас до обеих шин ЦАП. + radius = max(math.hypot(x - cx, y - cy) for x, y in points) # Ограничение радиуса сохраняет диапазон напряжений при любом угле поворота. + return tuple(tuple((.85 * (x - cx) / radius, .85 * (y - cy) / radius) for x, y in stroke) for stroke in strokes) # Радиус 0,85 оставляет минимум 7,5 % Vref с каждой стороны при полном масштабе. + +def _dual_point(key, t): # Значение t задаёт долю общего повторяющегося периода двух каналов. + a = 2 * math.pi * t # Основная фаза за один аппаратный кадр проходит полный оборот. + functions = { # Нормированные амплитуды всех форм не превышают единицу по модулю. + "quadrature": lambda: (math.sin(a), math.cos(a)), # Квадратурная пара имеет одинаковые частоты и сдвиг 90°. + "phase_120": lambda: (math.sin(a), math.sin(a + 2 * math.pi / 3)), # Сдвиг 2π/3 соответствует 120 электрическим градусам. + "harmonic_1_2": lambda: (math.sin(a), math.sin(2 * a)), # Вторая координата содержит вторую гармонику первой. + "beats_5_6": lambda: (math.sin(5 * a), math.sin(6 * a)), # Разность частот составляет одно колебание за общий кадр. + "triangle_sine": lambda: (1 - 4 * abs((2 * t) % 1 - .5), math.sin(2 * a)), # Треугольник и синус совершают по два колебания за кадр. + "square_quadrature": lambda: (1 if t < .5 else -1, 1 if (t + .25) % 1 < .5 else -1), # Два меандра заполнения 50 % смещены на четверть периода. + "pwm_25_75": lambda: (1 if t < .25 else -1, 1 if t < .75 else -1), # Длительности высоких уровней составляют 25 и 75 % периода. + "amplitude_modulation": lambda: ((.6 + .4 * math.sin(a)) * math.sin(8 * a), .6 + .4 * math.sin(a)), # Второй канал совпадает с верхней огибающей восьмипериодной несущей относительно общего смещения Vref/2. + "chirp_iq": lambda: (math.sin(2 * math.pi * (2 * t + 6 * t * t)), math.cos(2 * math.pi * (2 * t + 6 * t * t))), # Производная фазы задаёт линейный рост частоты от 2 до 14 циклов на кадр. + "ring_down": lambda: (math.exp(-4 * t) * math.sin(8 * a), math.exp(-4 * t) * math.cos(8 * a)), # За кадр амплитуда уменьшается в e⁴ раз, после чего начинается следующий пакет. + } # Каждый ключ каталога имеет самостоятельную аналитическую форму пары. + return tuple(.85 * value for value in functions[key]()) # Ограничение амплитуды 85 % полудиапазона оставляет запас 7,5 % Vref до шин питания. + +def _cat_bezier(start, first, second, end, steps): # Кубическая кривая возвращает только новые вершины; общий конец соседних дуг не дублируется. + return tuple(((1-t)**3*start[0]+3*(1-t)**2*t*first[0]+3*(1-t)*t*t*second[0]+t**3*end[0], (1-t)**3*start[1]+3*(1-t)**2*t*first[1]+3*(1-t)*t*t*second[1]+t**3*end[1]) for t in (i/steps for i in range(1,steps+1))) # Формула Бернштейна сохраняет гладкие щёки, спину и лапы внутри выпуклой оболочки контрольных точек. + +def cat_strokes(phase): # Одна поза содержит 125 точек плавного силуэта; 32 позы сохраняют прежнюю аппаратную таблицу из 4000 пар. + angle=2*math.pi*phase; bob=.018*math.cos(2*angle) # Один замкнутый шаговый цикл задаёт плавное движение диагональных лап и небольшое покачивание корпуса. + body=((.26,.38),) # Начинаем у загривка: плечи, спина, круп и живот образуют органичный контур вместо правильного овала. + for first,second,end,steps in (((.08,.52),(-.30,.48),(-.44,.40),4),((-.60,.39),(-.67,.25),(-.60,.13),4),((-.53,-.06),(-.40,-.13),(-.20,-.06),4),((.12,-.11),(.28,.02),(.27,.20),5),((.31,.30),(.29,.35),(.26,.38),3)): # Пять дуг распределяют 20 новых точек между спиной, крупом, животом, грудью и шеей. + body+=_cat_bezier(body[-1],first,second,end,steps) # Касательные на соседних дугах меняются плавно и замыкают туловище за 21 точку. + head=((.20,.43),)+_cat_bezier((.20,.43),(.12,.55),(.17,.69),(.26,.72),4)+((.26,.90),(.39,.76)) # Левая щека округлена, а две вершины сохраняют естественный острый кончик левого уха. + head+=_cat_bezier(head[-1],(.45,.80),(.55,.80),(.61,.75),4)+((.78,.87),(.75,.63)) # Плавный лоб соединяет два уха; правое ухо имеет отдельные кончик и наружное основание. + head+=_cat_bezier(head[-1],(.89,.52),(.89,.36),(.74,.29),6)+_cat_bezier((.74,.29),(.63,.18),(.39,.17),(.26,.30),6)+_cat_bezier((.26,.30),(.21,.34),(.18,.38),(.20,.43),4) # Щёки и подбородок замыкаются округлой мордочкой; всего 29 точек вместо прежнего угловатого многоугольника. + tail=((-.54,.29),)+_cat_bezier((-.54,.29),(-.77,.30),(-.99,.61),(-.84,.83),5)+_cat_bezier((-.84,.83),(-.74,1.00),(-.48,.98),(-.56,.78),6) # Две плавные дуги образуют приподнятый хвост с округлым загнутым кончиком, всего 12 координат. + tail=tuple((x+.05*math.sin(angle+.8*i/11)*(i/11)**2,y+bob+.02*math.cos(angle+i/11)*i/11) for i,(x,y) in enumerate(tail)) # Покачивание усиливается к концу хвоста; основание следует за корпусом без разрывов. + legs=[] # Каждая лапа сохраняет собственный контур и фазу, чтобы передняя и дальняя пары не слипались. + for root,offset in ((-.45,0),(-.28,math.pi),(.07,math.pi),(.20,0)): # Диагональные пары чередуют опору и перенос, сохраняя узнаваемую походку. + stride=.14*math.sin(angle+offset); foot_y=-.41+.10*max(0,math.cos(angle+offset))**2 # Во время переноса лапа идёт вперёд и поднимается по гладкой дуге; у земли вертикальная скорость равна нулю. + start=(root,.07+bob); ankle=(root+stride,foot_y+.015) # Корень спрятан внутри туловища, голень заканчивается немного выше подушечки лапы. + legs.append((start,)+_cat_bezier(start,(root+stride*.18,-.04),(root+stride*.25,foot_y+.09),ankle,4)+((ankle[0]+.045,foot_y-.015),(ankle[0]+.09,foot_y+.005))) # Семь точек сглаживают колено/голень и добавляют округлый носок вместо прямоугольного окончания. + whiskers=tuple(((.62 if side>0 else .49,.41+bob),(x,y+bob)) for side,x,y in ((1,.97,.54),(1,.99,.41),(1,.96,.29),(-1,.24,.54),(-1,.22,.41),(-1,.25,.29))) # Шесть независимых усов по две точки остаются хорошо различимыми по сторонам мордочки. + eyes=tuple(tuple((cx+.045*math.cos(2*math.pi*i/7),.54+bob+.035*math.sin(2*math.pi*i/7)) for i in range(8)) for cx in (.40,.66)) # Два небольших замкнутых овальных глаза получают по восемь точек вместо двух коротких прямых. + nose=(((.50,.43+bob),(.61,.43+bob),(.555,.38+bob),(.50,.43+bob)),) # Маленький треугольный нос занимает четыре координаты между глазами и усами. + mouth=(((.48,.35+bob),(.555,.30+bob),(.63,.35+bob)),) # Короткая улыбка делает мордочку узнаваемой, не увеличивая аппаратный бюджет фигуры. + strokes=(tuple((x,y+bob) for x,y in body),tuple((x,y+bob) for x,y in head),tail,*legs,*whiskers,*eyes,*nose,*mouth) # Сохраняем порядок деталей: тело/голова/хвост, четыре лапы, шесть усов, глаза, нос и рот. + return tuple(tuple((.72*x,.72*y) for x,y in stroke) for stroke in strokes) # Общий масштаб 0,72 удерживает новый округлый контур и хвост в допустимом диапазоне при любом повороте XY. + + +def boat_strokes(phase): # Один кадр содержит 30 неподвижных точек кораблика и 50 точек двух движущихся рядов волн. + hull = ((-.56, -.05), (-.20, -.05), (.20, -.05), (.58, -.05), (.42, -.26), (0, -.29), (-.36, -.26), (-.56, -.05)) # Восемь вершин замыкают корпус с поднятыми бортами и выделенным днищем без зависимости от фазы волн. + mast = ((0, -.05), (0, .72)) # Мачта закреплена на палубе и остаётся вертикальной во всех кадрах сцены. + main_sail = ((-.035, .65), (-.035, .34), (-.035, .03), (-.47, .03), (-.25, .34), (-.035, .65)) # Шесть координат левого треугольного паруса сохраняют его полный замкнутый контур. + fore_sail = ((.035, .55), (.25, .30), (.47, .03), (.25, .03), (.035, .03), (.035, .55)) # Шесть координат переднего паруса создают второй узнаваемый треугольник без поворота или качания. + pennant = ((0, .72), (.21, .65), (0, .58), (0, .72)) # Четыре точки замыкают неподвижный флажок на вершине мачты. + stripe = ((-.43, -.13), (-.15, -.16), (.20, -.16), (.46, -.13)) # Полоса на борту подчёркивает форму корпуса и также не зависит от движения воды. + upper_wave, lower_wave = [], [] # Два самостоятельных контура воды движутся с одной скоростью и имеют разные высоты и фазы. + for index in range(25): # По 25 точек на ряд дают 50 координат воды и общий бюджет ровно 80 пар на кадр. + x = -.85 + 1.70 * index / 24 # Фиксированное горизонтальное окно содержит обе границы воды и два полных периода волны. + angle = 2 * math.pi * (2 * index / 24 - phase) # Убывание фазы при фиксированном X перемещает гребни вправо, а полный цикл возвращает исходную воду. + upper_wave.append((x, -.36 + .05 * math.sin(angle) + .012 * math.sin(2 * angle))) # Небольшая вторая гармоника делает гребни выразительнее; верхняя вода остаётся ниже неподвижного днища. + lower_wave.append((x, -.55 + .04 * math.sin(angle + .8))) # Нижняя волна сдвинута на 0,8 радиана и создаёт глубину воды без изменения положения кораблика. + return tuple(tuple((.78 * x, .78 * y) for x, y in stroke) for stroke in (hull, mast, main_sail, fore_sail, pennant, stripe, upper_wave, lower_wave)) # Общий коэффициент 0,78 удерживает даже крайние точки воды в допустимом радиусе при любом пользовательском повороте сцены. + +def _resample_strokes(strokes, count): # Аппаратный лимит делится между позами без удаления отдельных деталей фигуры. + total = sum(map(len, strokes)) # Доли исходных контуров определяют бюджет точек для каждой видимой детали. + budgets = [max(2, round(count * len(stroke) / total)) for stroke in strokes] # Минимум две точки сохраняет даже короткий отдельный контур. + largest = max(range(len(strokes)), key=lambda index: len(strokes[index])) # Коррекция округления применяется к самому длинному контуру с достаточным запасом точек. + budgets[largest] += count - sum(budgets) # Каждый геометрический кадр получает ровно согласованное число пар. + result = [] # Контуры остаются отдельными и сохраняют прежний порядок аппаратного обхода. + for stroke, budget in zip(strokes, budgets): # Уменьшаем только плотность точек внутри одной детали, не соединяя разные контуры. + closed = stroke[0] == stroke[-1] # Явно замкнутая кривая должна сохранить совпадение первой и последней координат. + samples = [] # Новые опорные точки распределяются вдоль исходного параметрического порядка. + for index in range(budget): # Бюджет фиксирует одинаковую длительность всех поз анимации. + position = index * (len(stroke) - 1) / (budget - 1) if closed else index * len(stroke) / budget # Замкнутые контуры включают оба конца, а периодические без дубликата учитывают последний переход к началу. + source = int(position) # Целая часть выбирает исходный линейный участок фигуры. + a, b = stroke[source], stroke[(source + 1) % len(stroke)] # Циклический сосед сохраняет замыкание параметрических контуров. + fraction = position - source # Дробная часть задаёт положение между двумя исходными вершинами. + samples.append((a[0] + fraction * (b[0] - a[0]), a[1] + fraction * (b[1] - a[1]))) # Линейная выборка не создаёт выход за диапазон исходной геометрии. + result.append(tuple(samples)) # Границы отдельных деталей сохраняются для предпросмотра и аппаратной пары. + return tuple(result) # Один компактный кадр пригоден для многократных геометрических преобразований. + +def _square_stroke(radius, angle): # Компактная рамка сохраняет все четыре точных угла и полное замыкание при бюджете 20 пар. + vertices = tuple((radius * math.cos(angle + index * math.pi / 2), radius * math.sin(angle + index * math.pi / 2)) for index in range(4)) # Четыре вершины лежат на окружности заданного нормированного радиуса с шагом 90 градусов. + points = [] # Отдельные стороны включают свои конечные вершины до перехода к следующему контуру. + for side in range(4): # Каждая сторона получает одинаковую длительность аппаратного обхода. + a, b = vertices[side], vertices[(side + 1) % 4] # Последняя сторона возвращается точно к исходной вершине квадрата. + for sample in range(5): # Пять точек на сторону, включая оба конца, дают ровно 20 пар на рамку. + fraction = sample / 4 # Четыре равных интервала покрывают всю сторону, сохраняя последние 20 % контура перед переходом к другой детали. + points.append(b if sample == 4 else (a[0] + fraction * (b[0] - a[0]), a[1] + fraction * (b[1] - a[1]))) # Явная конечная вершина замыкает квадрат без разрыва или потери угла из-за округления. + return tuple(points) # Первая и последняя координаты совпадают, поэтому яркий контур целиком виден в каждой позе. + +def _figure_frames(key, motion): # Создаёт замкнутый цикл изменения самой геометрии и общий бюджет не более 4000 пар. + if key == "laser_tunnel" and motion == "twist": # Для туннеля важны неподвижная внешняя рамка и разные углы вложенных квадратов. + frames = [] # Пятьдесят поз дают плавное скручивание и обратный ход за один аппаратный период. + for index in range(50): # По 80 точек на позу сохраняют полный объём 4000 пар. + twist = 1.8 * (1 - math.cos(2 * math.pi * index / 50)) / 2 # Угол от 0 до 1,8 радиана и обратно меняется плавно, без разрыва скорости на границе периода. + frames.append(tuple(_square_stroke(radius, math.pi / 4 + twist * depth / 3) for depth, radius in enumerate((.85, .612, .3825, .17)))) # Глубина 0 фиксирует внешнюю рамку; по 20 пар сохраняют все углы и полностью замкнутые стороны каждого вложенного квадрата. + return tuple(frames) # Одинаковые контуры и индексы вершин позволяют проверить обратимость геометрического движения. + count, frame_count = (200, 20) if key == "butterfly" else (100, 40) # Сложной бабочке оставляем 200 точек на позу; остальным фигурам достаточно 100 при 40 кадрах/с. + original = _resample_strokes(_xy_strokes(key), count) # Исходная форма подготавливается один раз, а не заново на каждом тике GUI. + frames = [] # Весь цикл хранится в неизменяемом задании и попадает в CSV целиком. + for index in range(frame_count): # Последняя поза не дублирует первую; возврат замыкается следующим аппаратным периодом. + phase = 2 * math.pi * index / frame_count # Полный оборот фазы соответствует одному геометрическому циклу. + deformation = (1 - math.cos(phase)) / 2 # Плавный ход 0…1…0 обеспечивает скручивание и последующее раскручивание. + strokes = [] # Каждая поза сохраняет исходный набор деталей и их порядок. + for stroke in original: # Деформация применяется ко всем координатам фигуры вокруг общего центра. + points = [] # Точки одной детали преобразуются независимо от частоты обхода контура. + for x, y in stroke: # Нормированные координаты находятся внутри радиуса 0,85 до электрического масштабирования. + angle = phase if motion == "rotate" else 2.4 * deformation * max(0, 1 - math.hypot(x, y) / .85) if motion == "twist" else 0 # Скручивание усиливается к центру, а обычное вращение сохраняет относительную форму. + scale_x = 1 - .35 * deformation if motion in ("pulse", "breathe") else 1 # Пульсация плавно уменьшает ширину фигуры, сохраняя запас до шин ЦАП. + scale_y = 1 - (.15 if motion == "breathe" else .35) * deformation if motion in ("pulse", "breathe") else 1 # Разные масштабы дыхания превращают окружность в эллипс, чтобы движение было видно у симметричной фигуры. + points.append((scale_x * (x * math.cos(angle) - y * math.sin(angle)), scale_y * (x * math.sin(angle) + y * math.cos(angle)))) # Поворот и сжатие сохраняют допустимый радиус при любой фазе цикла. + strokes.append(tuple(points)) # Полный контур одной позы виден одновременно, без бегущего светового хвоста. + frames.append(tuple(strokes)) # Все детали текущей позы сохраняются перед переходом к следующей фазе движения. + return tuple(frames) # Размер каждого цикла равен 4000 пар и укладывается в аппаратный предел 4096. + +def generate_example(kind, key, vref=3.3, rate=None, amplitude=1.0, rotation=0.0, animation="none"): # Параметр анимации выбирает полный аппаратный цикл движения фигуры; статический API остаётся совместимым с прежними заданиями. + if kind not in ("dual", "xy"): # Список режимов ограничен двухканальными временными сигналами и геометрией XY. + raise ValueError("Неизвестный тип примера") # Ошибка вызывающего кода не должна выбирать другой каталог. + catalogue = DUAL_EXAMPLES if kind == "dual" else XY_EXAMPLES + XY_EXTRA_EXAMPLES # Кот и кораблик добавляются после десяти основных фигур без подмены прежних примеров. + metadata = next((entry for entry in catalogue if entry[0] == key), None) # Постоянный ключ определяет русское имя и описание выбранного примера. + if metadata is None: # Неизвестные или перепутанные ключи явно отвергаются. + raise ValueError("Неизвестный пример: " + key) # Сообщение содержит конкретный отсутствующий идентификатор. + if animation not in dict(ANIMATION_MODES): # Явная проверка исключает незаметную подмену опечатанного режима статической картинкой. + raise ValueError("Неизвестная анимация фигуры: " + animation) # Ошибка выявляется до создания и загрузки частичной таблицы. + motion = ({"circle": "breathe", "heart": "pulse", "laser_tunnel": "twist", "butterfly": "pulse"}.get(key, "rotate") if animation == "auto" else animation) if kind == "xy" else "none" # Режим по фигуре выбирает заметное движение даже для вращательно-симметричной окружности. + motion = {"walking_cat": "walk", "boat_waves": "waves"}.get(key, motion) # Специальные сцены сохраняют заданное движение: у кота работают лапы и хвост, а у кораблика движется исключительно вода. + if rate is None: # Частота по умолчанию зависит от полного размера анимированного цикла. + rate = ANIMATION_SAMPLE_RATE if motion != "none" else SAMPLE_RATE # Любой полный цикл из 4000 пар длится одну секунду; статический контур сохраняет прежние 40 кГц. + if not math.isfinite(vref) or not 0 < vref <= 3.6 or not math.isfinite(amplitude) or not 0 < amplitude <= 1 or not math.isfinite(rotation): # Положительный масштаб до 100 % и Vref до 3,6 В сохраняют электрический диапазон обоих выходов. + raise ValueError("Проверьте Vref, масштаб 0…100 % и угол поворота") # Некорректные параметры не формируют частично пригодный кадр. + if type(rate) is not int or not 1 <= rate <= 1_000_000: # Частота ограничена тем же пределом 1 Мвыб/с, что основной генератор. + raise ValueError("Частота отсчётов должна быть 1…1000000 Гц") # Аппаратный профиль дополнительно проверяется при переносе в редактор. + if key in ("walking_cat", "boat_waves"): # Специальная анимация строится напрямую из неизменных и движущихся деталей сцены. + frame_generator = cat_strokes if key == "walking_cat" else boat_strokes # Выбор функции не смешивает общий поворот фигуры с движением её отдельных элементов. + frame_count = 32 if key == "walking_cat" else 50 # Коту нужны 125 точек на плавный силуэт, кораблик сохраняет прежние 50 поз по 80 точек. + frames = tuple(frame_generator(index / frame_count) for index in range(frame_count)) # Оба специальных примера сохраняют ровно 4000 пар, одну секунду цикла и аппаратный предел 4096. + else: # Обычные геометрические фигуры продолжают использовать существующие деформации и статичный режим. + frames = _figure_frames(key, motion) if motion != "none" else () # Предыдущие контуры сохраняют свой бюджет и поведение без изменений. + normalized = tuple(stroke for frame in frames for stroke in frame) if frames else _xy_strokes(key) if kind == "xy" else (tuple(_dual_point(key, i / SAMPLE_COUNT) for i in range(SAMPLE_COUNT)),) # Аппаратный кадр кота последовательно содержит все позы, а обычные примеры сохраняют 1000 пар. + angle = math.radians(rotation if kind == "xy" else 0) # Поворот изменяет только геометрические фигуры и не смешивает диагностические временные сигналы. + cosine, sine = math.cos(angle), math.sin(angle) # Тригонометрия рассчитывается один раз для всего кадра, а не для каждого отсчёта. + strokes = tuple(tuple(((1 + amplitude * (x * cosine - y * sine)) * vref / 2, (1 + amplitude * (x * sine + y * cosine)) * vref / 2) for x, y in stroke) for stroke in normalized) # Однополярные выходы центрируются на Vref/2, а XY поворачивается вокруг этого центра. + samples = tuple((index * 1000 / rate, x, y) for index, (x, y) in enumerate(point for stroke in strokes for point in stroke)) # Обе координаты каждого индекса имеют строго одно время в миллисекундах. + frame_size = len(frames[0]) if frames else 0 # Любая геометрическая анимация сохраняет одинаковое число контуров и пар во всех позах. + display_frames = tuple(strokes[index:index + frame_size] for index in range(0, len(strokes), frame_size)) if frames else () # Преобразованные позы сохраняют точные напряжения той же аппаратной последовательности. + return ExampleData(key, kind, metadata[1], metadata[2], rate, samples, display_frames[0] if frames else strokes, display_frames, motion) # Предпросмотр, экспорт и аппаратная пара получают одно движение и все его кадры.