"""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