Files
templates/python/set_devices/generator_data.py

228 lines
11 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.
"""Built-in waveforms and CSV input for the point-based signal generator."""
import csv
import io
import math
import json
from datetime import datetime
from set_devices.waveform import Waveform, generate as interpolate, load_recipe as load_interpolated_recipe
from set_devices.signal_reconstruction import dac12
# TIM6 clock of the supported STM32F407 emulator firmware. The device protocol
# does not currently expose its timer clock; keep this hardware profile explicit.
F407_TIMER_HZ = 72_000_000
F407_SAMPLE_RATES = tuple(sorted({
rate
for divisor in range(1, math.isqrt(F407_TIMER_HZ) + 1)
if F407_TIMER_HZ % divisor == 0
for rate in (divisor, F407_TIMER_HZ // divisor)
if 1 <= rate <= 1_000_000
}))
def nearest_f407_sample_rate(rate):
"""Nearest integer rate realizable by TIM6; ties prefer the lower rate."""
return min(F407_SAMPLE_RATES, key=lambda candidate: (abs(candidate - rate), candidate))
def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2):
if method != "samples":
return interpolate(points, sample_rate, vref, method, degree)
if type(sample_rate) is not int or not 1 <= sample_rate <= 1000000:
raise ValueError("Частота отсчётов должна быть 1…1000000 Гц")
if not math.isfinite(vref) or not 0 < vref <= 3.6:
raise ValueError("Vref должен быть в диапазоне 0…3,6 В")
if not 2 <= len(points) <= 4096:
raise ValueError("Нужно 2…4096 отсчётов")
if any(not math.isfinite(x) or not math.isfinite(y) or not 0 <= y <= vref for x, y in points):
raise ValueError("В точках нужны конечные числа, напряжение 0…Vref")
samples = tuple((i * 1000 / sample_rate, y) for i, (_, y) in enumerate(points))
return Waveform(samples, dac12([y for _, y in samples], vref), sample_rate, vref,
len(samples) * 1000 / sample_rate)
def load_recipe(text):
data = json.loads(text)
if not isinstance(data, dict) or data.get("method") != "samples":
return load_interpolated_recipe(text)
if data.get("version") != 1:
raise ValueError("Неизвестная версия задания генератора")
points = tuple((float(x), float(y)) for x, y in data["points_ms_volts"])
wave = generate(points, data["sample_rate"], data["vref"], "samples", data["degree"])
return wave.points, data
PRESET_NAMES = (
"Синусоида", "Треугольник", "Пила вверх", "Пила вниз", "Меандр",
"Импульс 10%", "Трапеция", "Ступенчатый", "Выпрямленная синусоида",
"Затухающая синусоида",
)
def preset_points(index, vref, count=200):
"""20 ms period, 200 samples at 10 kHz; finite one-sample pulse edges."""
if not 0 <= index < len(PRESET_NAMES):
raise ValueError("Неизвестный сигнал")
shapes = (
lambda t: .5 + .45 * math.sin(2 * math.pi * t),
lambda t: .05 + .9 * (1 - abs(2 * t - 1)),
lambda t: .05 + .9 * t,
lambda t: .95 - .9 * t,
lambda t: .95 if t < .5 else .05,
lambda t: .95 if .1 <= t < .2 else .05,
lambda t: .05 + .9 * max(0, min(1, (t - .1) / .2, (.9 - t) / .2)),
lambda t: .05 + .9 * min(4, int(t * 5)) / 4,
lambda t: .05 + .9 * abs(math.sin(2 * math.pi * t)),
lambda t: .5 + .45 * math.exp(-4 * t) * math.sin(8 * math.pi * t),
)
points = [(i * 20 / count, vref * shapes[index](i / count)) for i in range(count)]
points.append((20., points[0][1]))
return points
def signal_csv_channels(text):
"""Named channels in time-domain trend/processing exports."""
first = next((line for line in text.lstrip('\ufeff').splitlines() if line.strip()), '')
delimiter = ';' if ';' in first else '\t' if '\t' in first else ','
header = next(csv.reader([first], delimiter=delimiter), [])
normalized = [cell.strip().lower() for cell in header]
if normalized in (['time_ms', 'volts'], ['time_ms', 'volts', 'dac12']):
return []
if header and header[0].strip().lower() in ('timestamp', 'время [мс]', 'time [s]', 'time [ms]', 'time_ms'):
return header[1:]
return []
def _parse_trend_csv(text, channel):
first = next(line for line in text.splitlines() if line.strip())
delimiter = ';' if ';' in first else '\t' if '\t' in first else ','
rows = csv.reader(io.StringIO(text), delimiter=delimiter)
header = next(row for row in rows if any(c.strip() for c in row))
names = header[1:]
if len(set(header)) != len(header):
raise ValueError('Повторяющиеся имена каналов CSV')
if channel is None:
if len(names) != 1:
raise ValueError('Выберите один канал CSV: ' + ', '.join(names))
channel = names[0]
if channel not in names:
raise ValueError('Канал CSV не найден: ' + str(channel))
column = header.index(channel)
kind = header[0].strip().lower()
points = []
origin = None
previous = None
for number, row in enumerate(rows, 2):
if not any(c.strip() for c in row):
continue
try:
if len(row) != len(header):
raise ValueError('число колонок не соответствует заголовку')
if kind == 'timestamp':
stamp = datetime.fromisoformat(row[0].strip().replace('Z', '+00:00'))
else:
stamp = float(row[0].strip().replace(',', '.'))
if not math.isfinite(stamp):
raise ValueError('время должно быть конечным')
if previous is not None and stamp <= previous:
raise ValueError('время должно строго возрастать')
previous = stamp
if not row[column].strip():
continue
value = float(row[column].strip().replace(',', '.'))
if not math.isfinite(value):
raise ValueError('значение должно быть конечным')
if origin is None:
origin = stamp
elapsed = ((stamp - origin).total_seconds() * 1000 if kind == 'timestamp'
else (stamp - origin) * (1000 if kind == 'time [s]' else 1))
points.append((elapsed, value))
except (ValueError, TypeError, OverflowError) as error:
raise ValueError(f'Строка {number}: {error}') from error
if len(points) < 2:
raise ValueError('Выбранный канал должен содержать минимум две точки')
return points, None
def resample_signal(points, rate):
"""Fit a long trend to the DAC table using linear interpolation, no endpoint."""
count = round(points[-1][0] * rate / 1000)
if not 2 <= count <= 4096:
raise ValueError('Таблица ЦАП должна содержать 2…4096 отсчётов')
result = []
index = 0
for i in range(count):
x = i * 1000 / rate
while index + 1 < len(points) - 1 and points[index + 1][0] < x:
index += 1
x0, y0 = points[index]
x1, y1 = points[index + 1]
result.append((x, y0 + (y1 - y0) * (x - x0) / (x1 - x0)))
return result
def parse_signal_csv(text, raw=False, channel=None):
"""Return points and optional inferred Fs for SETGUI's sampled CSV exports.
Ordinary CSV: time in ms and volts; last row defines the period.
SETGUI export (time_ms,volts,dac12): uniform samples without endpoint.
"""
text = text.lstrip("\ufeff")
if signal_csv_channels(text):
return _parse_trend_csv(text, channel)
first = next((line for line in text.splitlines() if line.strip()), "")
delimiter = ";" if ";" in first else "\t" if "\t" in first else ","
rows = [(number, row) for number, row in enumerate(
csv.reader(io.StringIO(text), delimiter=delimiter), 1) if any(cell.strip() for cell in row)]
if not rows:
raise ValueError("CSV пуст")
def numeric(cell):
return float(cell.strip().replace(",", "."))
header = [cell.strip().lower() for cell in rows[0][1]]
time_names = ("time_ms", "time", "время, мс", "время", "t")
volt_names = ("volts", "voltage", "напряжение, в", "напряжение", "v")
x_col = next((i for i, cell in enumerate(header) if cell in time_names), None)
y_col = next((i for i, cell in enumerate(header) if cell in volt_names), None)
sampled_export = x_col is not None and y_col is not None and "dac12" in header
if x_col is not None and y_col is not None:
rows = rows[1:]
else:
x_col, y_col = 0, 1
if not 2 <= len(rows) <= 4096:
raise ValueError("CSV должен содержать от 2 до 4096 строк данных")
points = []
for number, row in rows:
try:
x, y = numeric(row[x_col]), numeric(row[y_col])
except (ValueError, IndexError) as error:
raise ValueError(f"Строка {number}: нужны числовые время в мс и напряжение в В") from error
if not math.isfinite(x) or not math.isfinite(y):
raise ValueError(f"Строка {number}: значения должны быть конечными")
if x < 0 or (points and x <= points[-1][0]):
raise ValueError(f"Строка {number}: время должно быть неотрицательным и строго возрастать")
points.append((x, y))
if points[0][0] != 0:
raise ValueError("Первая временная метка должна быть 0 мс")
rate = None
if sampled_export:
step = points[1][0]
if step <= 0 or any(not math.isclose(x, i * step, rel_tol=1e-8, abs_tol=1e-9)
for i, (x, _) in enumerate(points)):
raise ValueError("В экспортированной таблице отсчёты должны идти с постоянным шагом")
rate = round(1000 / step)
if not 1 <= rate <= 1000000 or not math.isclose(rate * step, 1000, rel_tol=1e-8):
raise ValueError("Частота CSV должна быть целым числом от 1 до 1 000 000 отсчётов/с")
if raw:
return points, rate
points.append((len(points) * 1000 / rate, points[0][1]))
# A 4096-sample export needs an endpoint too. Remove only exactly redundant
# control points, preserving the sampled linear waveform without resampling.
if len(points) > 4096:
for i in range(1, len(points) - 1):
if math.isclose(points[i][1] * 2, points[i-1][1] + points[i+1][1], abs_tol=1e-12, rel_tol=0):
del points[i]
break
else:
raise ValueError("Для импорта этой таблицы с конечной точкой нужно более 4096 опорных точек")
return points, rate