117а. Подготовка данных для модели: сбор с платы в CSV, разведочный анализ (EDA) в pandas, numpy и matplotlib, чистка, баланс классов и разметка
Раздел 117 нарезал сигнал на окна и считал из них признаки, раздел 118 разбивал записи на обучение и проверку и учил модель. Между ними есть шаг, который обычно пропускают и потом долго ищут ошибку в модели: посмотреть на сами данные. Сколько записей дошло целыми, у каждой ли есть метка, не упёрся ли датчик в шкалу АЦП, не сдвинулся ли ноль между днями, не попал ли один файл в набор дважды и не выдаёт ли класс что-то постороннее — номер прибора или день записи. Этот разбор называют разведочным анализом данных.
Здесь весь путь пройден на одном наборе: плата пишет кадры, компьютер их принимает и раскладывает в таблицу, pandas, numpy и matplotlib находят в ней неприятности одну за другой, и на выходе получаются чистый набор окон и короткий отчёт о данных. Данные синтетические, с зерном 1171, но устроены как настоящие: та же вибрация мотора, что в разделах 117 и 118.
Опоры: образ stm-eda:1 — pandas 3.1.0, numpy 2.3.3, matplotlib 3.11.2,
seaborn 0.13.2, scikit-learn 1.7.2 (собран на stm-ml:1); код кадра на C
собран gcc на компьютере и armclang из Keil MDK для Cortex-M4. Порт и CRC
со стороны Python — раздел 115в, форматы обмена — раздел 115г, USB-самописец
с программой на ПК — раздел 198б, SD-карта и FatFS — разделы 41а и 42.
Как это называется в документации
Оригинал Перевод exploratory data analysis (EDA) разведочный анализ данных data logger самописец: прибор, который пишет измерения frame, sync word кадр, синхрослово в его начале DataFrame, Series таблица и столбец pandas dtype тип столбца merge, join соединение таблиц по ключу resample, rolling window пересчёт на другой шаг времени, скользящее окно vectorization, boolean mask векторизация, логическая маска clipping, saturation насыщение: сигнал упёрся в край шкалы outlier, spike выброс, одиночный всплеск sensor drift дрейф датчика class imbalance перекос классов data augmentation размножение данных: сдвиг, шум data leakage утечка: модель видит то, чего не будет на приборе data card, data report отчёт о данных
117а.1 Откуда данные и как их пишет плата
Свой датчик на своей плате — главный источник: только так данные похожи на то, что прибор увидит в работе. Отдавать их можно двумя путями. Поток в UART или виртуальный COM-порт USB (CDC) — компьютер стоит рядом и пишет файл. Самописец на SD-карте — прибор работает на объекте один, файлы забирают потом. Открытые наборы данных годятся, чтобы опробовать метод, но они сняты чужим датчиком на чужом механизме, и модель, обученная только на них, на своём приборе почти всегда ошибается.
Что бы ни было каналом, записывать надо не только отсчёты. Нужны метка
времени, частота выборки и сведения о приборе: какой он, какая прошивка, сколько бит
у АЦП и какое опорное напряжение. Без них через месяц не понять, почему две
записи не похожи. Плата раздела пишет кадры двух видов: первый кадр записи —
строка dev=3A0F1C22;fw=1.2.0;fs=1024;n=64;adc=12;vref=3300, дальше — кадры
по 64 отсчёта. Каждый кадр — синхрослово A5 5A, тип, длина, номер, время
первого отсчёта в микросекундах и CRC-16:
size_t frame_seal(uint8_t *out, uint8_t type, uint16_t seq, uint32_t t_us, uint8_t len)
{
out[0] = FR_SYNC0;
out[1] = FR_SYNC1;
out[2] = type;
out[3] = len;
put16(out + 4, seq);
put16(out + 6, (uint16_t)t_us);
put16(out + 8, (uint16_t)(t_us >> 16));
put16(out + FR_HEAD + len, crc16_ccitt(out + 2, FR_HEAD - 2u + len));
return FR_HEAD + len + 2u;
}
Кадр с отсчётами занимает 140 байт и несёт 62 500 мкс сигнала. При 1024 Гц это 2240 байт в секунду — 19,4 % от 11 520 байт в секунду, которые пропускает UART на 115 200 бод. Та же запись текстом, строкой «время,код» на отсчёт, заняла 17 байт на отсчёт — 17 408 байт в секунду: в UART на 115 200 бод она уже не помещается. CSV удобен на компьютере, а с платы выгоднее двоичный кадр.
armclang для Cortex-M4 с -Os собрал четыре функции кадра в 278 байт кода;
с -O2 они занимают 956 байт: компилятор разворачивает восемь шагов CRC.
CRC здесь поразрядный, без таблицы: таблица на 256 значений заняла бы ещё
512 байт флеш-памяти. Сколько тактов уходит на кадр, не мерилось.
117а.2 Приём на компьютере: кадры, CRC, разрывы
Набор — 40 файлов по 4 с, в каждом 4096 отсчётов: 64 кадра с отсчётами
и кадр со сведениями о приборе. Кадры собирает тот же C, что на плате, —
frame117b.c, собранный gcc на компьютере, — а кодировщик на Python
повторяет формат:
def frame(typ, seq, t_us, pay):
body = struct.pack("<BBHI", typ, len(pay), seq, t_us) + pay
return b"\xa5\x5a" + body + struct.pack("<H", crc16(body))
Все 40 файлов совпали байт в байт: Python понимает кадр так же, как его
пишет C. Это первая проверка любого стенда сбора. Если в строке формата
struct перепутан порядок байт или размер поля, ошибка появится здесь, а не
месяцем позже в весах модели. crc16 — одна строка,
binascii.crc_hqx(b, 0xFFFF), и это тот же CRC-16/CCITT-FALSE, что считает
плата.
Между платой и файлом в опыте три кадра пропали — плата не успела их отправить, — а в двух помеха сбила по биту. Читатель ищет синхрослово, проверяет CRC и при ошибке сдвигается на байт:
while (i := buf.find(b"\xa5\x5a", i)) >= 0 and i + 12 <= len(buf):
typ, n, seq, t_us = struct.unpack_from("<BBHI", buf, i + 2)
end = i + 12 + n
if end > len(buf) or crc16(buf[i + 2:end - 2]) != struct.unpack_from("<H", buf, end - 2)[0]:
Принято 2555 кадров из 2560, ошибок CRC 2. Все 163 520 принятых отсчётов совпали с тем, что плата отправила: битый кадр отброшен целиком, и в таблицу не попало ни одного неверного числа. Разрыв видно по времени кадра: шаг 62 500 мкс, всё другое — потеря. Но время у платы 32-битное, счётчик микросекунд переполняется раз в 71,6 мин, и у одного прибора это случилось посреди записи. Простая разность нашла 6 разрывов, разность по модулю 2³² — настоящие 5:
gaps += int((np.diff(t) % 2**32 != DT_US).sum()) # время 32-битное: разность — по модулю 2^32
Принятое пишется в CSV: столбцы rec, t_s, adc, 3 588 343 байта на
163 520 строк. Тот же сигнал в кадрах занимал 360 920 байт. Колоночный
формат Parquet был бы и меньше, и быстрее, но pandas пишет его через
библиотеку pyarrow или fastparquet, а в образе нет ни той ни другой:
to_parquet отвечает ImportError. Ставить их сюда не стали — CSV для
набора в мегабайты хватает.
117а.3 pandas: таблица, типы, метаданные и журнал
read_csv угадывает типы сам: rec — строки (str), t_s — float64,
adc — int64. Восемь байт на 12-битный отсчёт — расточительно; таблица
занимала в памяти 11 283 012 байт, а после смены типов — 1 146 892:
df = df.astype({"rec": "category", "t_s": "float32", "adc": "uint16"})
Метка класса — не в данных платы: плата не знает, исправен ли мотор. Метки
приходят из журнала оператора: запись, мотор, класс, день, кто снимал.
Таблица записей — сведения из первых кадров плюс журнал. Соединение лучше
делать с контролем: validate остановит работу, если запись попала в журнал
дважды, а indicator покажет записи без строки в журнале:
rec_tab = rec_tab.merge(labels, on="rec", how="left", indicator=True, validate="one_to_one")
Так нашлась запись r017: оператор её не вписал, и класса у неё нет. Гадать
класс по сигналу нельзя — это разметка «на глаз», о ней раздел 118. Запись
откладывается. Дубликат ищут не по имени, а по содержимому: хеш отсчётов
каждой записи и duplicated:
rec_tab["sha1"] = samples.groupby("rec").adc.agg(lambda a: hashlib.sha1(a.to_numpy().tobytes()).hexdigest()).to_numpy()
Записи r008 и r033 одинаковы до отсчёта: один файл выгрузили дважды и дважды вписали в журнал. Дубликат опаснее, чем кажется: если копии попадут в обучение и в проверку, модель «узнает» запись, которую уже видела.
value_counts по записям — 24 нормы, 10 дисбалансов, 5 подшипников.
crosstab по прибору и классу показывает то, чего в журнале глазами не
видно:
| Прибор | Прошивка | норма | дисбаланс | подшипник |
|---|---|---|---|---|
| 3A0F1C22 | 1.2.0 | 24 | 10 | 0 |
| 7C41D905 | 1.3.1 | 0 | 0 | 5 |
Все подшипники сняты другим прибором — на стенде с заведомо плохим подшипником. К этому вернёмся в 117а.6.
Отсчёты разворачиваются в сетку «номер отсчёта × запись» — pivot
и reindex на 0…4095. Там, где кадр пропал, встают NaN, и тип
столбцов становится float64: целое число в pandas по умолчанию не умеет
быть пустым. Пропусков — 320, ровно пять кадров по 64 отсчёта. Пересчёт на
шаг 250 мс показывает, в каких окнах их не хватает:
cnt = wide.set_axis(pd.to_timedelta(wide.index / FS, unit="s")).resample("250ms").count()
Окон по 250 мс в записи 16, неполных — 5, по одному на каждую потерю.
117а.4 Графики: сначала смотреть
Рисунок собран из шести графиков одного прогона, matplotlib и seaborn, каждый — одна-две строки:
ax[1, 0].scatter(w.rms, w.band, s=7, color=COL[c], alpha=0.6, label=c, lw=0)
1. Временной ряд. Отрезок записи r021: вершины синусоиды срезаны на кодах 0 и 4095. Это насыщение — датчик или усилитель вышли за шкалу АЦП. СКЗ и спектр такого окна врут: часть сигнала просто потеряна.
2. Скользящее СКЗ. Запись r005: первые полсекунды СКЗ растёт — мотор разгоняется, — потом держится ровно. Разрыв около 1,19 с — потерянный кадр номер 20.
3. Гистограммы по классам. По одному СКЗ норму с подшипником не разделить: их окна лежат в одном диапазоне. Дисбаланс правее.
4. Диаграмма рассеяния. СКЗ против доли энергии в полосе 100–248 Гц (раздел 117): подшипник стоит отдельно. Видно и другое: точки подшипника лежат пятью тесными кучками, по одной на запись. Пять кучек — это пять моторов, а не пятьсот.
5. Матрица корреляций. Доля энергии в полосе и пик-фактор связаны на 0,89: оба признака видят удары подшипника, и второй мало что добавляет к первому. Тревожная клетка другая: смещение нуля и полоса — −0,56. Смещение нуля к дефекту отношения не имеет, и сильная связь значит, что класс связан с прибором.
6. Средний спектр. У всех классов пик 25 Гц — частота вращения, у подшипника ещё горб около 160 Гц. Это та полоса, на которой стоит признак раздела 117.
117а.5 numpy: маски вместо циклов
Проверки на насыщение, выбросы и разгон — это сравнения по всем отсчётам сразу. В numpy их пишут логическими масками: одна строка на правило, и никакого цикла по 163 840 отсчётам на Python.
Насыщение — три отсчёта подряд на краю шкалы:
rail = (X <= 0) | (X >= 4095)
flat = rail[:-2] & rail[1:-1] & rail[2:] # три отсчёта подряд на краю шкалы — плоская вершина
Плоские вершины нашлись в r021 и r024 — двух записях сильного дисбаланса, 3053 отсчёта на краю шкалы. Чинить такие окна нечем, их откладывают. Но вывод шире: отложены как раз самые сильные дисбалансы, и модель их не увидит. Правильное лечение — на приборе: датчик с большим диапазоном или меньшее усиление перед АЦП (раздел 14).
Выбросы — одиночный отсчёт далеко от медианы пяти соседей. Порог — в 12 раз больше типичного отклонения этой же записи, но не меньше 400 кодов, иначе сильный тон подшипника 160 Гц сам сойдёт за выброс:
med = wide.rolling(5, center=True).median().to_numpy()
thr = np.maximum(12 * np.nanmedian(np.abs(r_), axis=0), 400)
spike = big & ~near # выброс — одиночный: соседи в норме, до шкалы далеко
Нашлось 8 отсчётов в 4 записях — ровно те всплески от помехи, что были заложены в опыт; среди них копия r033, у неё те же два всплеска, что у r008. Выброс заменяется медианой соседей. Тот же расчёт, написанный циклом по отсчётам, дал те же 163 840 чисел. Цикл годится, чтобы проверить маску, но рабочий код — маски.
Разгон — скользящее СКЗ по 256 отсчётам и первый момент, когда оно дошло до 90 % установившегося:
runup = (rms_roll >= 0.9 * steady).idxmax() / FS # первая секунда, где СКЗ дошло до 90 % установившегося
В середине — 0,502 с, самое позднее — 0,562 с: первые три окна по 256 отсчётов в каждой записи — разгон, а не работа, и они отбрасываются.
Дрейф между днями — groupby по дню и прибору:
| День, прибор | Среднее напряжение датчика |
|---|---|
| 21.10, 3A0F1C22 | 1,650 В |
| 22.10, 3A0F1C22 | 1,698 В |
| 22.10, 7C41D905 | 1,610 В |
Ноль первого прибора за день ушёл на 48 мВ — около 60 кодов АЦП. СКЗ и спектр это не трогает: окно и так очищается от постоянной составляющей (раздел 117). Но любой признак, который смотрит на сырой уровень, будет различать дни, а не моторы. Дрейф чувствительности опаснее — с ним разбирается калибровка крепления в разделе 118.
117а.6 Перекос классов, утечка и отчёт о данных
После чистки из 640 окон остались 466 из 36 записей. Отброшены 32 окна
двух отложенных записей, 114 окон разгона, 3 окна с разрывом и 25 окон
с насыщением. По классам: норма — 298 окон, дисбаланс — 103, подшипник — 65.
describe по СКЗ чистых окон: 466 окон, среднее 0,395 В, разброс 0,169 В,
от 0,172 до 0,869 В.
Перекос классов. Подшипников в четыре с лишним раза меньше нормы. Что
с этим делать — по порядку пользы. Первое — собрать ещё: пять записей
подшипника — это пять моторов, и никакая обработка не добавит шестой.
Второе — веса классов в обучении (раздел 118). Для этого набора
compute_class_weight("balanced") дал 0,52 для нормы, 1,51 для дисбаланса
и 2,39 для подшипника. Третье — размножение: окно сдвигают на 64 отсчёта
и добавляют к копии слабый шум. Из 65 окон подшипника так получилось 455.
Это те же пять записей, и размножать можно только обучающую часть, уже
после разбиения по записям. Иначе сдвинутая копия окна из проверки окажется
в обучении — та же утечка, что в разделе 118.
Утечка через метаданные. Дерево с одним вопросом, обученное только на столбце «прибор», находит все 65 окон подшипника без единой ложной тревоги. По одному смещению нуля — 51 из 65, ложных тревог 9:
p = DecisionTreeClassifier(max_depth=1, random_state=117).fit(col, yb).predict(col)
Модель, которой дали такой столбец, выучит «стенд с плохим подшипником», а на приборе линии такого стенда не будет. Номер прибора, прошивку, день и сырой уровень сигнала в признаки не берут. Но и без них набор с этим перекосом опасен: прибор и день подшипника оставляют следы и в самом сигнале. Лечится это только сбором — записями подшипника с приборов линии.
Отчёт о данных — то, что прикладывают к набору вместе с файлом окон
clean117b.csv. Его пишет тот же скрипт, и из него видно, чем набор плох,
ещё до обучения:
ОТЧЁТ О ДАННЫХ — вибрация моторов, 1024 Гц, окно 256
файлов 40, кадров принято 2555 из 2560, ошибок CRC 2, разрывов времени 5
без строки в журнале: r017 — отложена
дубликат: r008 = r033 — оставлена первая
упор в шкалу АЦП: r021, r024 — окна с плоскими вершинами отложены
выбросы: 8 отсчётов в 4 записях заменены медианой соседей
разгон: до 0,56 с — первые 3 окна записи отброшены
смещение нуля: 10-21 3A0F1C22 — 1,650 В, 10-22 3A0F1C22 — 1,698 В, 10-22 7C41D905 — 1,610 В
окон всего 640, в чистом наборе 466 из 36 записей
по классам: норма 298, дисбаланс 103, подшипник 65
ВНИМАНИЕ: все записи подшипника — прибор 7C41D905, прошивка 1.3.1, день 10-22;
dev, fw, день и смещение нуля в признаки не брать; нужны записи подшипника с приборов линии
Дальше с этим набором работает раздел 118: метки на записи целиком, разбиение по записям, кросс-проверка.
117а.7 Грабли
Модель на компьютере хорошая, на плате — ерунда, хотя код признаков тот же. Читатель кадров разбирает не то, что пишет плата: порядок байт, размер поля, знак. Свой кодировщик на Python обязан дать те же байты, что C, — сверять до сбора данных.
В записи «разрыв» на ровном месте. 32-битный счётчик микросекунд переполнился, раз в 71,6 мин. Разность времени — по модулю 2³².
Столбец кодов АЦП вдруг стал float64. В нём появились NaN от
потерянных кадров. Это не ошибка, а пропуски; считать их через isna
и не заполнять нулями — ноль для АЦП законное значение.
win.clip & … падает с непонятной ошибкой. Столбец назван так же, как
метод таблицы: clip, count, mean. Через точку pandas отдаёт метод.
Писать win["clip"] или назвать столбец иначе.
На тоне подшипника «выбросы» через отсчёт. Порог выбросов задан в кодах без учёта самого сигнала. Порог — от разброса этой же записи, выброс — одиночный.
Класс угадывается подозрительно легко. Его выдаёт прибор, день, прошивка
или смещение нуля. crosstab класса с каждым столбцом метаданных — до
обучения.
Окон много, а моторов мало. Сдвиг и шум размножают окна, а не моторы. Считать записи, а не окна, и размножать только обучающую часть.
Самые сильные дефекты выпали из набора. Их сигнал упёрся в шкалу АЦП, и чистка их отложила. Смотреть, что именно отброшено, и чинить диапазон на приборе.
117а.8 Итоги
- Плата пишет двоичный кадр со временем, номером, CRC и сведениями о приборе: 140 байт на 64 отсчёта, 19,4 % UART на 115 200 бод; текстом то же не помещается — 17 408 байт в секунду.
- Кодировщик на Python совпал с C во всех 40 файлах; читатель принял 2555 кадров из 2560 и ни одного неверного отсчёта.
- Время 32-битное: простая разность дала 6 разрывов, по модулю 2³² — 5.
mergeсindicatorнашёл запись без метки, хеш — дубликат,crosstab— все подшипники на одном приборе.- Маски numpy: насыщение в 2 записях, 8 выбросов в 4 записях, разгон до 0,562 с, дрейф нуля на 48 мВ за день.
- Чистый набор — 466 окон из 36 записей; подшипника 65 окон, веса классов 0,52 / 1,51 / 2,39.
- Утечка: один столбец «прибор» находит все 65 окон подшипника — такие столбцы в признаки не берут, а записи подшипника собирают с приборов линии.
Полные исходники
frame117b.c — кадр записи на плате и на компьютере. Остальное —
sim117b.py (записи), eda117b.py (приём, чистка, отчёт) и fig117b.py
(рисунок) — в _verify/ml117b/.
/* Раздел 117а: кадр записи вибрации — плата пишет, ПК читает (eda117b.py).
* Кадр: A5 5A | тип | длина | номер (2) | время первого отсчёта, мкс (4) | данные | CRC-16 (2).
* Числа — младшим байтом вперёд; CRC-16/CCITT-FALSE от типа до конца данных.
* На плате кадр собирается из половины буфера DMA АЦП и уходит в UART или USB CDC.
* С -DFRAME_HOST main() читает записи из stdin и пишет по файлу кадров на запись —
* так проверка сверяет C с читателем на Python. Проверка — _verify/ml117b_check.py. */
#include <stdint.h>
#include <stddef.h>
#define FR_SYNC0 0xA5u
#define FR_SYNC1 0x5Au
#define FR_META 1u /* данные — строка key=value: прибор, прошивка, частота */
#define FR_DATA 2u /* данные — FR_N отсчётов АЦП по 2 байта */
#define FR_N 64u /* отсчётов в кадре: 62,5 мс при 1024 Гц */
#define FR_HEAD 10u /* синхро 2 + тип 1 + длина 1 + номер 2 + время 4 */
#define FR_MAX (FR_HEAD + 2u * FR_N + 2u)
uint16_t crc16_ccitt(const uint8_t *p, size_t n)
{
uint16_t c = 0xFFFFu;
while (n--) {
c ^= (uint16_t)(*p++ << 8);
for (int i = 0; i < 8; i++)
c = (c & 0x8000u) ? (uint16_t)((c << 1) ^ 0x1021u) : (uint16_t)(c << 1);
}
return c;
}
static void put16(uint8_t *p, uint16_t v)
{
p[0] = (uint8_t)v;
p[1] = (uint8_t)(v >> 8);
}
/* дописать заголовок и CRC к данным, уже лежащим в out + FR_HEAD; вернуть длину кадра */
size_t frame_seal(uint8_t *out, uint8_t type, uint16_t seq, uint32_t t_us, uint8_t len)
{
out[0] = FR_SYNC0;
out[1] = FR_SYNC1;
out[2] = type;
out[3] = len;
put16(out + 4, seq);
put16(out + 6, (uint16_t)t_us);
put16(out + 8, (uint16_t)(t_us >> 16));
put16(out + FR_HEAD + len, crc16_ccitt(out + 2, FR_HEAD - 2u + len));
return FR_HEAD + len + 2u;
}
size_t frame_data(uint8_t *out, uint16_t seq, uint32_t t_us, const uint16_t *adc)
{
for (unsigned i = 0; i < FR_N; i++)
put16(out + FR_HEAD + 2u * i, adc[i]);
return frame_seal(out, FR_DATA, seq, t_us, 2u * FR_N);
}
size_t frame_meta(uint8_t *out, uint16_t seq, uint32_t t_us, const char *kv)
{
uint8_t n = 0;
while (kv[n] && n < 2u * FR_N) {
out[FR_HEAD + n] = (uint8_t)kv[n];
n++;
}
return frame_seal(out, FR_META, seq, t_us, n);
}
#ifdef FRAME_HOST
#include <stdio.h>
#include <stdlib.h>
static int rd(void *p, size_t n) { return fread(p, 1, n, stdin) == n; }
static uint32_t le32(const uint8_t *b) { return b[0] | b[1] << 8 | b[2] << 16 | (uint32_t)b[3] << 24; }
int main(int argc, char **argv)
{
uint8_t len, b8[8], fr[FR_MAX];
char kv[256], name[512];
int rec = 0, frames = 0;
while (rd(&len, 1)) {
if (!rd(kv, len) || !rd(b8, 8))
return 1;
kv[len] = 0;
uint32_t t0 = le32(b8), ns = le32(b8 + 4);
uint16_t *adc = malloc(ns * 2u);
for (uint32_t i = 0; i < ns; i++) {
uint8_t s[2];
if (!rd(s, 2))
return 1;
adc[i] = (uint16_t)(s[0] | s[1] << 8);
}
snprintf(name, sizeof name, "%s/r%03d.bin", argc > 1 ? argv[1] : ".", ++rec);
FILE *f = fopen(name, "wb");
if (!f)
return 1;
fwrite(fr, 1, frame_meta(fr, 0, t0, kv), f);
for (uint32_t k = 0; k < ns / FR_N; k++) /* время первого отсчёта: 64 отсчёта = 62 500 мкс */
fwrite(fr, 1, frame_data(fr, (uint16_t)(k + 1), t0 + k * 62500u, adc + k * FR_N), f);
frames += 1 + (int)(ns / FR_N);
fclose(f);
free(adc);
}
printf("recs=%d\nframes=%d\nframe_bytes=%u\n", rec, frames, (unsigned)FR_MAX);
return 0;
}
#else
/* на плате: половина буфера DMA готова — собрать кадр и отдать передатчику */
volatile uint16_t g_adc[FR_N];
uint8_t g_tx[FR_MAX];
volatile size_t g_len;
static uint16_t seq;
void SystemInit(void) {}
int main(void)
{
g_len = frame_meta(g_tx, seq++, 0, "dev=3A0F1C22;fw=1.2.0;fs=1024;n=64;adc=12;vref=3300");
for (uint32_t t_us = 0;; t_us += 62500u)
g_len = frame_data(g_tx, seq++, t_us, (const uint16_t *)g_adc);
}
#endif
Дальше: раздел 118 «Обучение маленькой модели в контейнере» — от окон с признаками к весам в прошивке.