Embedded HandbookПриложения
Все разделы › XIV. Машинное обучение на микроконтроллере
STM32F407 · уровень средний · 18 мин чтения
Открыть в интерактивном справочнике

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 Графики: сначала смотреть

Шесть графиков разведки данных из прогона в образе stm-eda:1: запись r021 сильного дисбаланса упирается в коды 0 и 4095 плоскими вершинами; скользящее СКЗ записи r005 растёт первые полсекунды разгона и рвётся на потерянном кадре; гистограммы СКЗ окон по классам — норма и подшипник лежат вместе, дисбаланс правее; на диаграмме рассеяния СКЗ и доли энергии 100–248 Гц подшипник стоит отдельно выше 0,25; в матрице корреляций доля энергии в полосе связана с пик-фактором на 0,89 и со смещением нуля на минус 0,56; средний спектр окна у всех классов имеет пик 25 Гц, у подшипника ещё горб около 160 Гц

Рисунок собран из шести графиков одного прогона, 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 Итоги

Полные исходники

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 «Обучение маленькой модели в контейнере» — от окон с признаками к весам в прошивке.

Открыть в интерактивном справочнике