228 lines
11 KiB
Python
228 lines
11 KiB
Python
"""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
|