"""Digital CSV/JSON/SAL/DSL conversion using the shared capture readers. SAL v3 encoding follows DSLogic_Logic_2/dslogic_script/tests/generate_test_sal.py. Only digital signals are exported; analyzer settings and analog data are not copied. """ import csv import heapq import itertools import json import math from pathlib import Path import struct import tempfile import uuid import zipfile from .files import read_capture, ImportCancelled from .csv_import import convert_csv, _ones, BLOCK_SAMPLES, MAX_PACKED_BYTES from .sal_metadata import metadata FORMATS = {'csv': 'CSV — цифровые переходы', 'json': 'JSON — цифровые переходы', 'dsl': 'DSL — DSView', 'sal': 'SAL — Logic 2 (v3)'} def _check(cancel): if cancel(): raise ImportCancelled() def _csv(capture, path, progress, cancel): with path.open('w', encoding='utf-8', newline='') as stream: writer = csv.writer(stream) writer.writerow(['Time [s]', *[c.name for c in capture.channels]]) events = heapq.merge([capture.start, capture.end], *(c.edges for c in capture.channels)) for index, (time, _) in enumerate(itertools.groupby(events)): if index % 4096 == 0: _check(cancel) progress(int(99 * (time-capture.start) / max(capture.end-capture.start, 1e-30))) writer.writerow([repr(time), *[c.level_at(time) for c in capture.channels]]) def _json(capture, path, progress, cancel): names = ['Time [s]', *[c.name for c in capture.channels]] if len(set(names)) != len(names): raise ValueError('Для JSON имена каналов должны быть уникальны и отличаться от Time [s].') with path.open('w', encoding='utf-8', newline='') as stream: stream.write('[\n') events = heapq.merge([capture.start, capture.end], *(c.edges for c in capture.channels)) for index, (time, _) in enumerate(itertools.groupby(events)): if index % 4096 == 0: _check(cancel) progress(int(99 * (time-capture.start) / max(capture.end-capture.start, 1e-30))) if index: stream.write(',\n') row = dict(zip(names, [time, *[c.level_at(time) for c in capture.channels]])) stream.write(' ' + json.dumps(row, ensure_ascii=False, allow_nan=False)) stream.write('\n]\n') # Времена переводятся в целые отсчёты одинаково для SAL и DSL. # Округление допускается лишь в пределах погрешности, без ресемплинга. def _grid(capture, rate, cancel): rate = rate or capture.sample_rate if not math.isfinite(rate) or rate <= 0: raise ValueError('Для экспорта в SAL/DSL укажите частоту дискретизации в Гц.') def sample(time): value = (time - capture.start) * rate rounded = round(value) if not math.isclose(value, rounded, rel_tol=0, abs_tol=1e-5): raise ValueError('Временные метки не попадают на сетку выбранной частоты. Увеличьте частоту.') return rounded count = sample(capture.end) # CSV final row is a sample, binary capture end is exclusive. if capture.format.endswith('CSV') or capture.format == 'JSON': count += 1 if not 0 < count < 2**53: raise ValueError('Недопустимая длительность записи.') edges = [] for channel in capture.channels: result = [] previous = 0 for i, time in enumerate(channel.edges): if i % 8192 == 0: _check(cancel) value = sample(time) if not previous < value < count: raise ValueError('Частота не позволяет сохранить все фронты.') result.append(value) previous = value edges.append(result) return rate, count, edges def _run(length): value = length - 1 if value < 64: return bytes([value]) shift = 7 while value >> shift >= 64: shift += 7 out = bytearray([64 | (value >> shift)]) for bit in range(shift - 7, -1, -7): out.append(((value >> bit) & 127) | (128 if bit else 0)) return out # SAL хранит длины серий уровней. Метаданные описывают цифровые каналы; # настройки анализаторов исходной записи сюда не переносятся. def _sal(capture, path, rate, count, edges, progress, cancel): if len(capture.channels) > 16: raise ValueError('Экспорт SAL поддерживает до 16 цифровых каналов.') meta = metadata() data = meta['data'] data['name'] = path.stem data['captureStartTime'] = dict(unixTimeMilliseconds=0, fractionalMilliseconds=0) data['captureNotes'] = 'Converted digital signals; source time origin: %s s' % capture.start data['renderViewState'] = dict(type='PanAndZoom', leftEdgeTimeSec=0, timeScaleSeconds=count/rate/10) data['captureSettings']['timerModeSettings']['stopAfterSeconds'] = count/rate data['legacySettings']['sampleRate'] = {'digital': rate} data['legacySettings']['enabledChannels'] = [dict(type='Digital', index=i) for i in range(len(edges))] data['rowsSettings'] = [dict(id=str(uuid.uuid4()), height=100, isMarkedHidden=False, type='channel', name=c.name, channel=dict(category='legacy', type='Digital', deviceChannel=i)) for i, c in enumerate(capture.channels)] with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as archive: archive.writestr('meta.json', json.dumps(meta, ensure_ascii=False)) for i, (channel, transitions) in enumerate(zip(capture.channels, edges)): encoded = bytearray() previous = 0 for j, boundary in enumerate(itertools.chain(transitions, [count])): if j % 8192 == 0: _check(cancel) encoded.extend(_run(boundary - previous)) previous = boundary header = b'' + struct.pack('' + struct.pack(' MAX_PACKED_BYTES: raise ValueError('Запись DSL превышает ограничение 2 ГиБ распакованных данных.') blocks = (padded+BLOCK_SAMPLES-1)//BLOCK_SAMPLES header = ('[version]\nversion = 3\n[header]\ndriver = virtual-session\n' 'device mode = 0\ncapturefile = data\n' f'total samples = {padded}\ntotal probes = {len(names)}\ntotal blocks = {blocks}\n' f'samplerate = {rate:.12f} Hz\ntrigger time = 0\ntrigger pos = 0\n' + ''.join(f'probe{i} = {name}\n' for i, name in enumerate(names))) session = dict(Device='virtual-session', DeviceMode=0, Version=3, Title='DSView v1.3.2', **{'Sample count': str(padded), 'Sample rate': str(rate), 'Max Height': '1X'}, decoder=[], channel=[dict(colour='default', enabled=True, index=i, name=name, strigger=0, type=10000, view_index=i) for i, name in enumerate(names)]) with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED, compresslevel=1) as archive: archive.writestr('header', header) archive.writestr('session', json.dumps(session, ensure_ascii=False)) archive.writestr('decoders', '[]') for i, (channel, transitions) in enumerate(zip(capture.channels, edges)): boundaries = iter(itertools.chain(transitions, [padded])) boundary = next(boundaries) level = channel.initial for block in range(blocks): _check(cancel) start = block*BLOCK_SAMPLES end = min(padded, start+BLOCK_SAMPLES) buffer = bytearray((end-start)//8) position = start while position < end: stop = min(end, boundary) if level: _ones(buffer, position-start, stop-start) position = stop if position == boundary: level ^= 1 boundary = next(boundaries, padded) _check(cancel) archive.writestr(f'L-{i}/{block}', buffer) progress((i*blocks+block+1)*99//(len(names)*blocks)) return padded-count def convert_capture(source, destination, progress=lambda p: None, cancel=lambda: False, *, sample_rate=0, csv_options=None): """Export to a new file; cancel/failure never leaves a partial destination.""" source, destination = Path(source), Path(destination) suffix = destination.suffix.lower().lstrip('.') if suffix not in FORMATS or source.suffix.lower().lstrip('.') not in FORMATS: raise ValueError('Поддерживаются цифровые CSV, JSON, SAL и DSL.') if source.resolve() == destination.resolve() or destination.exists(): raise ValueError('Выберите новый выходной файл: существующие файлы не перезаписываются.') _check(cancel) # Нормализация расширенного входа получает первые 25% прогресса. # В рекурсивный вызов csv_options не передаётся: повторного прохода нет. if source.suffix.lower() == '.csv' and csv_options is not None: from .csv_options import normalize_csv with tempfile.TemporaryDirectory(prefix='.csv-options-', dir=destination.parent) as directory: normalized = Path(directory)/'normalized.csv' normalize_csv(source, normalized, csv_options, sample_rate, lambda p: progress(p*25//100), cancel) return convert_capture(normalized, destination, lambda p: progress(25+p*75//100), cancel, sample_rate=sample_rate) # Автоматическая сетка использует точный потоковый CSV-импортёр. # Явно заданную частоту проверяет _grid, её нельзя молча заменить. if source.suffix.lower() == '.csv' and suffix == 'dsl' and not sample_rate: result = convert_csv(source, destination, progress, cancel) return 'Готово: %s\nDSL: %s Гц; дополнение %s отсчётов; начало %s с.' % ( destination, result['samplerate'], result['padding'], result['origin_seconds']) capture = read_capture(source, lambda p: progress(p*35//100), cancel) padding = 0 # Сначала собираем временный архив рядом с назначением. Его владелец — # этот контекст: ошибка и отмена тоже удаляют промежуточный результат. with tempfile.TemporaryDirectory(prefix='.convert-', dir=destination.parent) as directory: temporary = Path(directory)/destination.name report = lambda p: progress(35+p*60//100) if suffix == 'csv': _csv(capture, temporary, report, cancel) elif suffix == 'json': _json(capture, temporary, report, cancel) else: rate, count, edges = _grid(capture, sample_rate, cancel) if suffix == 'sal': _sal(capture, temporary, rate, count, edges, report, cancel) else: padding = _dsl(capture, temporary, rate, count, edges, report, cancel) _check(cancel) # Режим xb защищает от гонки с другим процессом после проверки пути. # При ошибке удаляем только файл, который успели создать сами. created = False try: with destination.open('xb') as output: created = True with temporary.open('rb') as stream: while chunk := stream.read(1024*1024): _check(cancel) output.write(chunk) _check(cancel) except BaseException: if created: destination.unlink(missing_ok=True) raise progress(100) return 'Готово: %s\nКаналов: %d. Дополнение DSL: %d отсчётов.' % ( destination, len(capture.channels), padding)