103а. Пульсоксиметр MAX30102: фотоплетизмограмма по I2C, FIFO, выделение пульса и оценка SpO₂ — и почему это не медицинский прибор
В разделе 103 BH1750 мерил свет, который падает на датчик. MAX30102 свет светит сам — красным и инфракрасным светодиодами в палец — и мерит, сколько вернулось. С каждым ударом сердца крови в пальце чуть больше, и света возвращается чуть меньше: это фотоплетизмограмма, ФПГ. Из неё получают пульс, а из отношения двух цветов — то, что в пульсоксиметрах переводят в насыщение крови кислородом, SpO₂. Здесь — как это устроено, что внутри микросхемы, как подключить её к F103 и читать FIFO, как выделить удары и посчитать частоту, что такое отношение R и почему без калибровки на людях оно не превращается в проценты, и что делают с сигналом движение, свет и нажим пальца.
Это учебный пример, а не медицинское изделие. Код и числа раздела проверены только на модели. Для диагностики, контроля лечения и любых решений о здоровье он не годится.
Опоры: RM0008 (DocID13902 Rev 17): таблица режимов выводов, с. 160;
AFIO_EXTICR3, с. 191; EXTI_FTSR, с. 211; приём больше двух байт
ведущим, способ 2, с. 762–763; приём одного байта, с. 764; I2C_CCR,
с. 783–784; I2C_TRISE, с. 784. DocID13587 Rev 17 (STM32F103x8/xB):
PB6 — I2C1_SCL, PB7 — I2C1_SDA, оба FT, PB5 без FT, с. 32; PB8 — FT,
с. 33; пороги входа, таблица 35, с. 62. Документа на MAX30102 в проекте
нет: адрес, регистры, коды частоты, ширины импульса, диапазона и тока,
поведение FIFO, флагов и вывода INT взяты из документации производителя и
заложены в модель — по документации производителя, не проверено. Номера
регистров, поля и коды совпадают с открытым драйвером родственной
MAX30101 (у неё есть ещё зелёный светодиод) в Zephyr 4.4.2 на диске, но
это не документ. Поэтому всё ниже проверяет согласие драйвера с моделью,
а не с микросхемой и тем более не с пальцем. Все числа — прогон драйвера
_verify/ppg103b/max103b.c и обработки ppg103b.c на компьютере
(gcc 12.2.0) со своей моделью MAX30102 как ведомого I2C и синтетической
ФПГ на Python 3.11; прошивка собрана armclang 6.24 под Cortex-M3.
Как это называется в документации
Оригинал Перевод photoplethysmography (PPG) фотоплетизмограмма, ФПГ: сколько света вернулось из ткани pulse oximeter, SpO₂ пульсоксиметр; насыщение артериальной крови кислородом AC / DC component пульсирующая / постоянная составляющая perfusion index (PI) индекс перфузии: AC/DC, обычно в процентах ratio of ratios, R отношение (AC/DC) красного к (AC/DC) ИК LED pulse amplitude ( LEDx_PA)ток светодиода pulse width ( LED_PW)ширина импульса светодиода — она же время накопления и разрядность ADC full-scale range ( SPO2_ADC_RGE)диапазон АЦП: фототок, которому соответствует верх шкалы FIFO almost full ( A_FULL)FIFO почти полон: прерывание FIFO rollover перезапись старых отсчётов при полном FIFO ambient light cancellation (ALC) вычитание внешнего света motion artifact артефакт движения refractory period рефрактерный период: после удара новый не ищут inter-beat interval (IBI) интервал между ударами
103а.1 Принцип: свет через палец
Светодиод светит в палец, фотодиод рядом ловит то, что рассеялось и вернулось. Большую часть света съедают кожа, мышцы, кость и венозная кровь, и эта часть от удара к удару не меняется — постоянная составляющая, DC. Артерии с каждым ударом чуть расширяются, крови на пути света становится больше, и вернувшегося света — меньше. Это пульсирующая составляющая, AC, и она маленькая: в модели глубина модуляции ИК — 2 % от постоянной. На выходе АЦП удар — не всплеск, а провал: кровь свет поглощает.
Пульсу хватило бы и одного цвета. Два нужны для SpO₂: гемоглобин с кислородом и без него поглощают красный и ИК свет по-разному. Отношение AC к DC в каждом канале убирает яркость светодиода, толщину пальца и цвет кожи — всё, что одинаково давит оба отсчёта. Отношение двух таких отношений
R = (AC_red / DC_red) / (AC_ir / DC_ir)
зависит от того, что в крови. Но как именно — физика не даёт готовой формулы: свет в ткани рассеивается, путь у разных цветов разный, длины волн у разных светодиодов разные. Связь R с процентами снимают опытом на людях — об этом 103а.5.
Свет здесь мерит не люксы, а фототок в нанамперах, и не освещённость комнаты, а палец: окружающий свет — помеха, которую микросхема старается вычесть (103а.6).
103а.2 Микросхема: светодиоды, АЦП и FIFO на 32 отсчёта
По документации производителя внутри MAX30102 — красный и ИК светодиоды со своими источниками тока, фотодиод, АЦП с вычитанием внешнего света, датчик температуры кристалла и FIFO на 32 отсчёта. Адрес на шине — 0x57 (байт в линии 0xAE для записи и 0xAF для чтения, 17.2). Регистры, которые нужны здесь (номера — как в драйвере MAX30101 у Zephyr):
| Регистр | Номер | Что в нём |
|---|---|---|
INT_STATUS1 |
0x00 | A_FULL (бит 7), PPG_RDY (бит 6), ALC_OVF (бит 5), PWR_RDY (бит 0) |
INT_ENABLE1 |
0x02 | какие флаги опускают INT |
FIFO_WR_PTR, OVF_COUNTER, FIFO_RD_PTR |
0x04…0x06 | указатель записи, счёт потерянных, указатель чтения |
FIFO_DATA |
0x07 | отсчёты: по 3 байта на канал |
FIFO_CONFIG |
0x08 | SMP_AVE [7:5], FIFO_ROLLOVER_EN [4], FIFO_A_FULL [3:0] |
MODE_CONFIG |
0x09 | SHDN [7], RESET [6], MODE [2:0]: 2 — только красный, 3 — SpO2 |
SPO2_CONFIG |
0x0A | ADC_RGE [6:5], SR [4:2], LED_PW [1:0] |
LED1_PA, LED2_PA |
0x0C, 0x0D | ток красного и ИК |
PART_ID |
0xFF | 0x15 |
Три кода в SPO2_CONFIG решают почти всё:
| Код | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
LED_PW: импульс |
69 мкс, 15 бит | 118 мкс, 16 бит | 215 мкс, 17 бит | 411 мкс, 18 бит |
ADC_RGE: верх шкалы |
2048 нА | 4096 нА | 8192 нА | 16384 нА |
SR: отсчётов в секунду |
50 | 100 | 200 | 400 |
У SR кодов восемь: дальше 800, 1000, 1600 и 3200. Длинный импульс —
больше накопленного света и больше бит, но не со всеми частотами он
совместим (по документации производителя). Отсчёт всегда лежит в 18
битах, выровненный по старшему: при 15 битах три младших — нули. Драйвер
Zephyr сдвигает его вправо, наш — оставляет в шкале 18 бит, тогда верх
шкалы один на все ширины: 262 143.
Ток светодиода — код от 0 до 255, по документации производителя около 0,2 мА на единицу; в модели ровно 0,2: 0x24 — 7,2 мА, 0xFF — 51 мА (в описании драйвера Zephyr предел назван 50 мА). Ток задаёт и яркость, и то, куда в шкале АЦП встанет постоянная составляющая (103а.7).
Здесь — режим SpO2 (3), 100 отсчётов в секунду, 411 мкс и 18 бит,
диапазон 4096 нА — 15,625 пА на единицу кода, ток 0x24 на обоих
светодиодах, без усреднения в микросхеме. FIFO_A_FULL = 15: INT
опускается, когда в FIFO осталось 15 свободных мест, то есть в нём 17
отсчётов. Без FIFO_ROLLOVER_EN полный FIFO новые отсчёты не берёт, а
считает их в OVF_COUNTER (так это описано и в привязке драйвера Zephyr).
103а.3 Подключение к F103 и инициализация
| Вывод модуля | STM32F103 | Назначение |
|---|---|---|
| VIN | 3,3 В | питание модуля |
| GND | GND | земля |
| SCL | PB6 | такт I2C1 |
| SDA | PB7 | данные I2C1 |
| INT | PB8 | прерывание, активный ноль, открытый сток |
По документации производителя логика MAX30102 питается от 1,8 В, а светодиоды — от 3,3 В и выше. Модули ставят свой стабилизатор на 1,8 В, и у части модулей подтяжки SDA, SCL и INT идут к этим 1,8 В, а не к 3,3 В — смотрите схему своего модуля (не проверено). Чем это грозит F103, видно из таблицы 35 (DocID13587, с. 62): гарантированная единица на входе — 0,65 × VDD, при 3,3 В это 2,145 В, и она проверяется на производстве. Пороги ниже — 1,546 В у выводов FT и 1,833 В у обычных — получены моделированием, не испытанием. Единица в 1,8 В ниже гарантированной, а для обычного вывода ниже даже расчётной. Поэтому INT заведён на PB8 — он FT (с. 33), — а не на соседний PB5 без FT (с. 32). PB6 и PB7 тоже FT (с. 32). Надёжнее всего — подтяжки к 3,3 В или преобразователь уровней (95а).
I2C1 — как в 15б.2: 400 кГц, CCR = F/S | 30, TRISE = 11. PB8 —
вход с подтяжкой (CNF = 10, MODE = 00, в ODR единица; RM0008, с.
160), EXTI8 по спаду: в AFIO_EXTICR3 (с. 191) код 0001 — порт B,
EXTI_FTSR (с. 211) — спад:
/* INT: спад на PB8 — в FIFO 17 отсчётов */
void EXTI9_5_IRQHandler(void)
{
EXTI->PR = 1u << 8;
irq = 1;
}
static void irq_init(void)
{
AFIO->EXTICR[2] = (AFIO->EXTICR[2] & ~0xFu) | 0x1u; /* EXTI8 ← PB8 */
EXTI->FTSR |= 1u << 8;
EXTI->IMR |= 1u << 8;
NVIC_EnableIRQ(EXTI9_5_IRQn);
}
Инициализация — проверить, что по адресу та микросхема, сбросить её, дождаться конца сброса, снять флаг включения и настроить. Режим пишется последним: с него начинаются отсчёты.
/* сброс, ожидание конца сброса, настройка; режим пишется последним — с него начинаются отсчёты */
int max_init(max_t *d, const max_cfg_t *c)
{
uint8_t v = 0;
int rc;
d->leds = c->mode == MAX_MODE_SPO2 ? 2u : 1u;
d->xfers = d->errors = d->lost = d->alc_ovf = 0;
if ((rc = max_get(d, MAX_PART_ID, &v)) != MAX_OK)
return rc;
if (v != MAX_PART_ID_VAL)
return MAX_E_ID; /* по адресу 0x57 не та микросхема */
if ((rc = max_reg(d, MAX_MODE_CONFIG, MAX_MODE_RESET)) != MAX_OK)
return rc;
for (uint32_t i = 0;; i++) { /* RESET сбрасывается сам */
max_port_delay_us(1000u);
if ((rc = max_get(d, MAX_MODE_CONFIG, &v)) != MAX_OK)
return rc;
if (!(v & MAX_MODE_RESET))
break;
if (i + 1u >= MAX_RESET_TRIES)
return MAX_E_RESET;
}
if ((rc = max_get(d, MAX_INT_STATUS1, &v)) != MAX_OK) /* снять PWR_RDY — INT отпущен */
return rc;
/* без FIFO_ROLLOVER_EN: в полный FIFO новые отсчёты не пишутся, их считает OVF_COUNTER */
if ((rc = max_reg(d, MAX_FIFO_CONFIG, (uint8_t)((c->ave & 7u) << 5 | (c->a_full & 0x0Fu)))) != MAX_OK)
return rc;
if ((rc = max_reg(d, MAX_SPO2_CONFIG, (uint8_t)((c->rge & 3u) << 5 | (c->sr & 7u) << 2 | (c->pw & 3u)))) != MAX_OK)
return rc;
if ((rc = max_reg(d, MAX_LED1_PA, c->led_red)) != MAX_OK)
return rc;
if ((rc = max_reg(d, MAX_LED2_PA, c->led_ir)) != MAX_OK)
return rc;
if ((rc = max_reg(d, MAX_INT_ENABLE1, MAX_INT_A_FULL)) != MAX_OK)
return rc;
return max_reg(d, MAX_MODE_CONFIG, (uint8_t)(c->mode & 7u));
}
В модели max_init — 10 обменов: чтение PART_ID, 09 40 (сброс),
чтение MODE_CONFIG, чтение INT_STATUS1, 08 0F, 0A 27, 0C 24,
0D 24, 02 80 и 09 03. На шине 800 мкс, вместе с паузой после
сброса — 1800 мкс. Чужой PART_ID (0x11 вместо 0x15) — отказ
MAX_E_ID, −3. Чтение INT_STATUS1 здесь не лишнее: после включения
питания в модели INT уже в нуле — флаг PWR_RDY, — и, пока его не
прочитали, спада на PB8 не будет никогда (103а.7). После max_init INT в
единице.
103а.4 Чтение FIFO: указатели, переполнение, время на шине
В FIFO два пятибитных указателя: куда микросхема запишет следующий
отсчёт и откуда его прочитают. Их разность по модулю 32 — сколько ждёт.
Беда в том, что 32 по модулю 32 — это 0: указатели равны и у пустого
FIFO, и у полного. Различить помогает OVF_COUNTER: если отсчёты
терялись, FIFO точно полон. Все три регистра читаются одним обменом —
номер регистра растёт после каждого байта:
/* сколько отсчётов ждёт в FIFO: указатели записи и чтения по 5 бит; равны — пусто или полно */
int max_fifo_count(max_t *d, uint32_t *n, uint32_t *ovf)
{
uint8_t reg = MAX_FIFO_WR_PTR, p[3]; /* WR_PTR, OVF_COUNTER, RD_PTR одним чтением */
int rc = xfer_done(d, max_port_xfer(d->addr, ®, 1, p, 3));
if (rc != MAX_OK)
return rc;
*ovf = p[1] & 0x1Fu;
*n = (uint32_t)(p[0] - p[2]) & (MAX_FIFO_DEPTH - 1u);
if (*n == 0u && *ovf != 0u)
*n = MAX_FIFO_DEPTH; /* отсчёты терялись — значит, FIFO полон */
return MAX_OK;
}
Сами отсчёты — одним длинным чтением FIFO_DATA. Здесь номер регистра
не растёт: каждые 6 байт (3 на красный, 3 на ИК) микросхема снимает из
FIFO следующий отсчёт. Байты идут старшим вперёд, значащие — 18 младших
бит:
/* всё, что есть в FIFO, одним чтением: по 3 байта на канал, в SpO2 сначала красный, потом ИК.
* lost — сколько отсчётов не поместилось: они были ПОСЛЕ прочитанных */
int max_read_fifo(max_t *d, uint32_t *red, uint32_t *ir, uint32_t maxn, uint32_t *got, uint32_t *lost)
{
uint8_t reg = MAX_FIFO_DATA, buf[MAX_FIFO_DEPTH * 6u];
uint32_t n, ovf, k = 3u * d->leds;
int rc;
*got = *lost = 0;
if ((rc = max_fifo_count(d, &n, &ovf)) != MAX_OK)
return rc;
if (n > maxn)
n = maxn;
*lost = ovf;
d->lost += ovf;
if (n == 0u)
return MAX_OK;
if ((rc = xfer_done(d, max_port_xfer(d->addr, ®, 1, buf, n * k))) != MAX_OK)
return rc;
for (uint32_t i = 0; i < n; i++) {
red[i] = max_unpack(&buf[i * k]);
ir[i] = d->leds == 2u ? max_unpack(&buf[i * k + 3u]) : 0u;
}
*got = n;
return MAX_OK;
}
OVF_COUNTER считает отсчёты, которые не поместились, — они были после
тех, что лежат в FIFO. Поэтому порядок такой: сначала обработать
прочитанные, потом ppg_skip(&ppg, n_lost) — сдвинуть счёт времени на
потерянные. Фильтры дыру не заполнят, но интервалы между ударами
останутся верными.
Приём на F103. Больше двух байт ведущий F103 принимает по способу 2
(RM0008, с. 762–763): когда осталось три байта, дождаться BTF, снять
ACK, прочитать N−2, снова BTF, STOP, прочитать N−1 и после RXNE —
N. Один байт — свой порядок (с. 764):
/* приём nrx байт после повторного старта: 1 байт — RM0008, с. 764; больше двух — способ 2, с. 762–763 */
static int rx_bytes(uint8_t *rx, uint32_t nrx)
{
int rc;
uint32_t i = 0;
if (nrx == 1u) {
I2C1->CR1 &= (uint16_t)~I2C_CR1_ACK; /* ACK снять до сброса ADDR */
(void)I2C1->SR2;
I2C1->CR1 |= I2C_CR1_STOP;
if ((rc = sr1_wait(I2C_SR1_RXNE)) != MAX_OK)
return rc;
rx[0] = (uint8_t)I2C1->DR;
I2C1->CR1 |= I2C_CR1_ACK;
return MAX_OK;
}
(void)I2C1->SR2; /* ADDR снят, ACK = 1 */
for (; i + 3u < nrx; i++) {
if ((rc = sr1_wait(I2C_SR1_RXNE)) != MAX_OK)
return rc;
rx[i] = (uint8_t)I2C1->DR;
}
if ((rc = sr1_wait(I2C_SR1_BTF)) != MAX_OK) /* осталось три: N−2 в DR, N−1 в сдвиговом, SCL прижат */
return rc;
I2C1->CR1 &= (uint16_t)~I2C_CR1_ACK;
rx[i++] = (uint8_t)I2C1->DR; /* N−2; пошёл приём N с NACK */
if ((rc = sr1_wait(I2C_SR1_BTF)) != MAX_OK)
return rc;
I2C1->CR1 |= I2C_CR1_STOP;
rx[i++] = (uint8_t)I2C1->DR; /* N−1 */
if ((rc = sr1_wait(I2C_SR1_RXNE)) != MAX_OK)
return rc;
rx[i] = (uint8_t)I2C1->DR; /* N */
I2C1->CR1 |= I2C_CR1_ACK;
return MAX_OK;
}
Время на шине. В модели прерывание — каждые 17 отсчётов, раз в
170 мс, и обработчик делает три обмена: INT_STATUS1, три указателя и
102 байта отсчётов.
| Обмен | Тактов SCL | 400 кГц | 100 кГц |
|---|---|---|---|
INT_STATUS1, 1 байт |
39 | ||
| указатели, 3 байта | 57 | ||
| 17 отсчётов, 102 байта | 948 | ||
| всё прерывание | 1044 | 2610 мкс | 10 440 мкс |
| доля шины | 1,54 % | 6,14 % |
Один отсчёт — 54 такта, 135 мкс на 400 кГц. За 60 с основного прогона обработчик сработал 352 раза, шина занята 1,53 % времени. После прерывания в FIFO ещё 15 свободных мест — 150 мс на ответ.
Опрос вместо прерывания. Если читать FIFO по таймеру, период решает всё. В модели 60 с при 72 ударах в минуту, ошибка ЧСС — средняя по отчётам:
| Период опроса | Чтений | Потеряно отсчётов | Насчитал OVF |
Ошибка ЧСС, со сдвигом / без |
|---|---|---|---|---|
| 170 мс | 352 | 0 | 0 | 0,42 / 0,42 уд/мин |
| 250 мс | 239 | 0 | 0 | 0,43 / 0,43 уд/мин |
| 320 мс | 187 | 2977 | 2884 | 1,96 / 51,44 уд/мин |
| 330 мс | 181 | 182 | 182 | 0,40 / 1,97 уд/мин |
| 400 мс | 150 | 1201 | 1201 | 0,47 / 17,66 уд/мин |
| 1000 мс | 59 | 4013 | 1829 | 46,45 / 28,27 уд/мин |
320 мс — ровно 32 отсчёта, и тут ловушка равных указателей: драйвер видит
0, и каждое второе чтение пустое (103а.7). 400 мс без сдвига времени — 88,2
удара в минуту вместо 71,4: интервалы короче, потому что потерянные
отсчёты выпали из счёта. На 1000 мс не спасает и сдвиг: OVF_COUNTER —
пять бит, больше 31 он не считает. Прерывание по A_FULL всех этих бед
не знает.
103а.5 Обработка: пульс, частота и отношение R
Цепочка на каждый канал — как в разделе 81 и 81а, только целыми числами (у F103 нет FPU). ФВЧ: постоянную составляющую ведёт экспоненциальное среднее с весом 1/32, его вычитают — срез около 100 / (2π · 32) = 0,5 Гц при 100 отсчётах в секунду. Результат переворачивают, чтобы удар шёл вверх. ФНЧ: скользящее среднее по 8 отсчётам — задержка 3,5 отсчёта, первый ноль на 12,5 Гц:
/* один канал: ФВЧ (минус медленное среднее), переворот, ФНЧ (среднее по 8) */
static int32_t ch_step(ppg_ch_t *c, uint32_t x, uint32_t pos, int first)
{
int32_t xq = (int32_t)(x << PPG_Q);
if (first)
c->dc = xq;
c->dc += (xq - c->dc) / (1 << PPG_DC_SHIFT);
int32_t ac = c->dc / (1 << PPG_Q) - (int32_t)x; /* кровь поглощает свет: удар — провал, переворачиваем */
c->sum += ac - c->ma[pos];
c->ma[pos] = ac;
c->y = c->sum / (int32_t)PPG_MA_N;
if (c->y < c->lo)
c->lo = c->y;
if (c->y > c->hi)
c->hi = c->y;
return c->y;
}
Пики. Порог — половина высоты пиков; высота забывается на 1/256 за отсчёт (за 2,56 с — примерно в e раз), чтобы после сильного удара порог вернулся вниз. Пока сигнал выше порога, ищется максимум; ушёл под порог — это кандидат в удары:
p->amp -= p->amp / 256; /* высота пиков забывается за несколько секунд */
p->thr = p->amp / 2;
if (y > p->thr && y > 0) {
if (!p->above || y > p->cand_y) {
p->cand = idx;
p->cand_y = y;
}
p->above = 1;
return 0;
}
if (!p->above)
return 0;
p->above = 0; /* спуск под порог: пик позади */
return accept(p);
Кандидат ближе 300 мс к прошлому удару — не удар (рефрактерный период: 200 ударов в минуту — это 300 мс): так отсекается второй горб той же волны и мелкая помеха. Интервал длиннее 2 с — пульс потерян, считать заново. ЧСС — 60 · fs, делённое на медиану последних 8 интервалов; медиана не боится одного лишнего или пропущенного удара:
/* кандидат прошёл: интервал, ЧСС, R и перфузия по размаху с прошлого удара */
static int accept(ppg_t *p)
{
uint32_t d = p->cand - p->last;
if (p->have_last && d < p->refract)
return 0; /* второй горб того же удара или помеха */
p->amp += (p->cand_y - p->amp) / 4;
int32_t ac_r = p->red.hi - p->red.lo, ac_i = p->ir.hi - p->ir.lo;
int32_t dc_r = p->red.dc / (1 << PPG_Q), dc_i = p->ir.dc / (1 << PPG_Q);
if (p->have_last && d <= p->ibi_max) {
push_last(p->ibi, &p->nibi, d);
if (ac_i > 0 && ac_r > 0 && dc_r > 0 && dc_i > 0) {
push_last(p->rr, &p->nr, (uint32_t)((uint64_t)ac_r * (uint32_t)dc_i * 1000u / ((uint64_t)ac_i * (uint32_t)dc_r)));
p->pi100 = (uint32_t)((uint64_t)ac_i * 10000u / (uint32_t)dc_i);
}
if (p->nibi >= 3u) {
uint32_t m = ppg_median(p->ibi, p->nibi);
p->hr10 = (600u * p->fs + m / 2u) / m;
p->r1000 = ppg_median(p->rr, p->nr);
}
} else {
p->nibi = p->nr = 0; /* пауза больше 2 с: всё сначала */
p->hr10 = p->r1000 = 0;
}
p->last = p->cand;
p->have_last = 1;
p->beats++;
p->red.lo = p->red.hi = p->red.y;
p->ir.lo = p->ir.hi = p->ir.y;
return 1;
}
Прогон на синтетической ФПГ. В модели: 72 удара в минуту, интервалы гуляют на 3 %, дыхание 0,25 Гц, шум, R модели 0,6; удары считаются с 5-й секунды. Первая оценка ЧСС — через 4,24 с после начала отсчётов (секунда на фильтры и три интервала). За 60 с найдено 65 ударов из 65 и один лишний, пик удара находится на 30 мс позже настоящего (фильтры), а сам удар признаётся ещё через 60–70 мс — когда сигнал уходит под порог. Средняя ошибка ЧСС — 0,52 удара в минуту, наибольшая — 1,64.
| ЧСС модели | Первая оценка | Найдено | Лишних | Ошибка средняя / наибольшая |
|---|---|---|---|---|
| 45 уд/мин | 6,28 с | 41 из 41 | 0 | 0,28 / 0,75 уд/мин |
| 72 уд/мин | 4,41 с | 66 из 66 | 1 | 0,38 / 1,52 уд/мин |
| 120 уд/мин | 2,71 с | 110 из 110 | 0 | 1,06 / 3,75 уд/мин |
| 180 уд/мин | 2,20 с | 162 из 163 | 2 | 1,84 / 8,1 уд/мин |
На 180 ударах интервал — 333 мс, рядом с рефрактерными 300 мс, и 94,4 %
отчётов уложились в 5 ударов в минуту. Если палец приложить не сразу:
пока его нет, постоянная ИК в модели — 2396 единиц, меньше порога
PPG_FINGER_MIN, и обработка молчит с флагом «нет пальца»; палец лёг на
2,99 с, первая ЧСС — на 7,47 с.
Отношение R. На каждом ударе обработка берёт размах сигнала с прошлого удара (это AC) и постоянную составляющую (DC) в обоих каналах и считает R × 1000 целыми, отчёт — медиана последних 8 ударов. В модели R — просто параметр: во сколько раз глубина модуляции красного больше, чем у ИК.
| R модели | 0,4 | 0,6 | 0,8 | 1,0 | 1,2 |
|---|---|---|---|---|---|
| R по прогону, медиана | 0,415 | 0,606 | 0,804 | 1,007 | 1,206 |
| разброс отчётов | 0,395…0,437 | 0,593…0,629 | 0,789…0,819 | 0,986…1,023 | 1,184…1,222 |
R вслед за моделью ходит, и фильтры его почти не портят: они одинаковы в обоих каналах. Индекс перфузии хуже: 1,69 % по прогону при 2 % в модели — ФНЧ срезает вершину удара.
Почему не проценты. Пульсоксиметр переводит R в SpO₂ по калибровочной кривой, которую для своей конструкции снимают на людях: у добровольцев меняют насыщение крови, сравнивают отсчёты прибора с анализом артериальной крови и строят зависимость. Она своя для каждой пары светодиодов, корпуса и места на теле. Формулы «R → %», которые ходят по примерам, сняты не для вашего модуля, и в этом разделе их нет намеренно. В модели R — число, придуманное моделью, и любой процент из него был бы выдумкой. Медицинский пульсоксиметр — это ещё и испытания, регистрация и контроль качества. У этого кода нет ничего из этого: он показывает, как устроена обработка, и не годится для оценки здоровья.
103а.6 Движение, внешний свет и нажим пальца
Движение. В модели палец шевелится на 20–30 с и 40–45 с: колебания 0,9, 1,7 и 2,9 Гц, в той же полосе, что пульс, и больше его по размаху.
| Отрезок | Найдено | Лишних | Ошибка средняя / наибольшая | В пределах 5 уд/мин |
|---|---|---|---|---|
| движение и 4 с после | 23 из 27 | 11 | 13,25 / 21,86 уд/мин | 35,3 % |
| покой | 38 из 38 | 0 | 0,40 / 2,48 уд/мин | 100 % |
Фильтр движение не уберёт: оно там же, где пульс. Хуже того, R во время движения — 1,008 против 0,604 в покое. В модели движение одинаковой долей меняет оба канала, а одинаковые доли дают R около 1 — значение, которое не значит ничего, кроме «палец шевелится». На деле обработке нужна оценка качества сигнала (например, акселерометр рядом, раздел 99) и право сказать «не знаю».
Внешний свет. По документации производителя микросхема перед каждым
импульсом мерит свет без светодиода и вычитает его — пока он не выходит
за предел, иначе ставит ALC_OVF. В модели лампа мерцает на 100,4 Гц,
предел — 4000 нА (допущение модели).
| Внешний свет | ALC_OVF |
Найдено ударов | ЧСС |
|---|---|---|---|
| нет | 0 из 352 чтений | 65 из 65 | ошибка 0,41 уд/мин |
| 2000 нА | 0 из 352 | 65 из 65 | ошибка 0,41 уд/мин |
| 5000 нА | 245 из 352 | 22 из 65 | ни одной оценки |
Пока вычитание справляется, свет не виден вовсе. Сверх предела мерцание
лампы проходит в отсчёты и забивает пульс. Флаг ALC_OVF драйвер
считает (dev.alc_ovf) — по нему прошивка может сказать «закройте палец
от света».
Нажим. Прижали сильно — кровь выдавлена, пульсирующей составляющей меньше. В модели нажим уменьшает глубину модуляции:
| Глубина модуляции | Индекс перфузии по прогону | Найдено | Лишних | Ошибка средняя / наибольшая |
|---|---|---|---|---|
| как есть | 1,66 % | 65 из 65 | 1 | 0,51 / 1,71 уд/мин |
| × 0,3 | 0,50 % | 64 из 65 | 5 | 1,06 / 15,63 уд/мин |
| × 0,1 | 0,15 % | 39 из 65 | 13 | 27,87 / 58,27 уд/мин |
При ×0,1 пульс тонет в дыхании и шуме. Слабый нажим — обратная беда: палец шевелится и пропускает свет сбоку, это два предыдущих случая. Индекс перфузии и есть простейшая оценка качества: очень маленький — показаниям не верить.
103а.7 Грабли
Отсчёты скачут, ударов вдвое больше, чем есть. Перепутан порядок
байт: младший принят за старший. В модели ИК вместо 119 936 стал 54 273,
за 30 с найдено 74 «удара» при 29 настоящих, и ЧСС вышла 166,7 вместо
71,5. Старший байт первым — max_unpack.
ЧСС завышена при опросе по таймеру. FIFO переполняется, отсчёты
теряются, а счёт времени этого не знает: опрос раз в 400 мс — 88,2 удара
в минуту вместо 71,4. Читать по A_FULL или сдвигать время на
OVF_COUNTER (103а.4). Сдвиг выручает, пока за чтение теряется не больше
31 отсчёта: в модели 330 и 400 мс — да, 1000 мс — нет.
В FIFO 32 отсчёта, а драйвер видит 0. Указатели равны: в модели после
32 отсчётов WR_PTR = RD_PTR = 0 и OVF_COUNTER = 0. Ещё два отсчёта —
и OVF_COUNTER = 2, тогда драйвер читает 32. Не ждать до полного FIFO:
прерывание при 17.
Постоянная упёрлась в верх шкалы, ударов нет. Ток светодиода слишком велик для диапазона:
| Ток | Диапазон | Постоянная ИК в модели | Насыщение | Найдено ударов за 30 с |
|---|---|---|---|---|
| 0x10, 3,2 мА | 4096 нА | 53 248 | нет | 30 из 30 |
| 0x24, 7,2 мА | 4096 нА | 119 808 | нет | 30 из 30 |
| 0x40, 12,8 мА | 4096 нА | 212 992 | нет | 30 из 30 |
| 0x50, 16 мА | 4096 нА | 266 240 | да | 23 из 30 |
| 0xFF, 51 мА | 4096 нА | 848 640 | да | 0 из 30 |
| 0xFF, 51 мА | 16384 нА | 212 160 | нет | 30 из 30 |
Выше 262 143 шкалы нет: верх удара срезан, при 0xFF срезано всё.
Обработка ставит PPG_F_SAT. Ток подбирают так, чтобы постоянная
стояла примерно на середине шкалы, или берут диапазон шире.
R около 1,67 вместо 0,6. Каналы перепутаны: первые три байта отсчёта — красный, вторые — ИК. В модели с перепутанными каналами R = 1,669 — почти ровно 1 / 0,6 = 1,667.
Модуль не отвечает или INT не читается. Подтяжки модуля к 1,8 В — ниже гарантированной единицы F103 2,145 В (103а.3). Вывод INT — на FT, а лучше подтяжки к 3,3 В.
Прерываний нет вообще. INT опущен с включения (PWR_RDY) или с
прошлого раза, а EXTI ловит только спад. max_init читает
INT_STATUS1; прошивка, кроме флага прерывания, проверяет и сам уровень
PB8.
Палец лёг — первые секунды тишина. Так и должно быть: секунда на фильтры и три интервала на медиану, в модели первая ЧСС — через 4,24 с.
103а.8 Итоги
- MAX30102 — красный и ИК светодиоды, фотодиод, АЦП до 18 бит и FIFO на 32 отсчёта по I2C, адрес 0x57; карта регистров — по документации производителя и драйверу MAX30101 в Zephyr.
- Здесь: SpO2, 100 отсчётов в секунду, 411 мкс, 4096 нА (15,625 пА на единицу), 7,2 мА; INT при 17 отсчётах — раз в 170 мс, 2610 мкс на 400 кГц, 1,54 % шины.
- Логика модуля — 1,8 В: гарантированная единица F103 — 2,145 В; INT — на вывод FT (PB8), подтяжки — к 3,3 В.
- FIFO: указатели равны и у пустого, и у полного; потерянные отсчёты —
по
OVF_COUNTER(до 31), время сдвигать после прочитанных. - Обработка: ФВЧ около 0,5 Гц, ФНЧ по 8 отсчётам, порог — половина высоты пиков, рефрактерный период 300 мс, медиана 8 интервалов. В модели — 65 ударов из 65, ошибка ЧСС 0,52 удара в минуту, первая оценка через 4,24 с.
- R в модели повторяет параметр модели (0,606 при 0,6), при движении тянется к 1. В проценты SpO₂ без калибровки на людях R не переводится.
- Учебный пример, не медицинское изделие: для диагностики и решений о здоровье не использовать.
Полные исходники
max103b.h — регистры, биты, коды, порт и функции драйвера MAX30102.
/* max103b.h — раздел 103а: драйвер MAX30102 (красный и ИК светодиоды, фотодиод, АЦП, FIFO) по I2C
* без регистров микроконтроллера. Драйвер говорит с микросхемой только через две функции порта
* (max_port_*): в прошивке fw103b.c их делает I2C1 STM32F103, в стенде host103b.c — модель model103b.py.
* Карта регистров и биты — по документации производителя; документа в проекте нет, номера регистров и
* поля совпадают с открытым драйвером родственной MAX30101 в Zephyr 4.4.2; с микросхемой не сверено. */
#ifndef MAX103B_H
#define MAX103B_H
#include <stdint.h>
/* ── регистры ─────────────────────────────────────────────────────────── */
#define MAX_INT_STATUS1 0x00u /* чтение снимает флаги и отпускает INT */
#define MAX_INT_ENABLE1 0x02u
#define MAX_FIFO_WR_PTR 0x04u /* 0x04…0x06 — запись, переполнение, чтение: читаются подряд */
#define MAX_OVF_COUNTER 0x05u
#define MAX_FIFO_RD_PTR 0x06u
#define MAX_FIFO_DATA 0x07u /* номер регистра при чтении не растёт: каждые 3 байта — следующий канал */
#define MAX_FIFO_CONFIG 0x08u /* SMP_AVE [7:5], FIFO_ROLLOVER_EN [4], FIFO_A_FULL [3:0] */
#define MAX_MODE_CONFIG 0x09u /* SHDN [7], RESET [6], MODE [2:0] */
#define MAX_SPO2_CONFIG 0x0Au /* ADC_RGE [6:5], SR [4:2], LED_PW [1:0] */
#define MAX_LED1_PA 0x0Cu /* ток красного светодиода */
#define MAX_LED2_PA 0x0Du /* ток ИК светодиода */
#define MAX_PART_ID 0xFFu
/* ── биты ─────────────────────────────────────────────────────────────── */
#define MAX_INT_A_FULL 0x80u /* в FIFO осталось FIFO_A_FULL свободных мест */
#define MAX_INT_PPG_RDY 0x40u /* новый отсчёт */
#define MAX_INT_ALC_OVF 0x20u /* вычитание внешнего света упёрлось в предел */
#define MAX_INT_PWR_RDY 0x01u /* питание появилось */
#define MAX_MODE_SHDN 0x80u
#define MAX_MODE_RESET 0x40u /* сам сбрасывается в 0 по окончании */
#define MAX_MODE_HR 0x02u /* только красный */
#define MAX_MODE_SPO2 0x03u /* красный и ИК */
#define MAX_FIFO_ROLLOVER 0x10u
#define MAX_ADDR 0x57u /* семибитный: 0xAE запись, 0xAF чтение */
#define MAX_PART_ID_VAL 0x15u
#define MAX_FIFO_DEPTH 32u
#define MAX_FULL_SCALE 0x3FFFFu /* 18 бит; при 15…17 битах младшие биты — нули */
#define MAX_RESET_TRIES 10u
enum { MAX_OK = 0, MAX_E_NACK = -1, MAX_E_BUS = -2, MAX_E_ID = -3, MAX_E_RESET = -4 };
typedef struct {
uint8_t mode; /* MAX_MODE_HR или MAX_MODE_SPO2 */
uint8_t sr; /* частота отсчётов, код 0…7: 50, 100, 200, 400, 800, 1000, 1600, 3200 Гц */
uint8_t pw; /* ширина импульса, код 0…3: 69, 118, 215, 411 мкс — 15…18 бит */
uint8_t rge; /* диапазон АЦП, код 0…3: 2048, 4096, 8192, 16384 нА */
uint8_t ave; /* усреднение в микросхеме, код 0…5: 1…32 отсчёта */
uint8_t a_full; /* прерывание, когда в FIFO осталось столько свободных мест */
uint8_t led_red, led_ir; /* ток светодиодов, код 0…255 */
} max_cfg_t;
typedef struct {
uint8_t addr;
uint8_t leds; /* каналов в отсчёте: 1 (HR) или 2 (SpO2) */
uint8_t status; /* последний INT_STATUS1 */
uint32_t xfers, errors; /* обменов по I2C и ошибок */
uint32_t lost; /* отсчётов потеряно — по OVF_COUNTER */
uint32_t alc_ovf; /* сколько раз внешний свет не вычелся */
} max_t;
/* ── порт: в прошивке — I2C1, в стенде — строки к модели ──────────────── */
/* ntx байт записи, затем (если nrx) повторный старт и nrx байт чтения; 0, MAX_E_NACK или MAX_E_BUS */
int max_port_xfer(uint8_t addr7, const uint8_t *tx, uint32_t ntx, uint8_t *rx, uint32_t nrx);
void max_port_delay_us(uint32_t us);
/* ── драйвер ──────────────────────────────────────────────────────────── */
int max_reg(max_t *d, uint8_t reg, uint8_t v);
int max_get(max_t *d, uint8_t reg, uint8_t *v);
int max_init(max_t *d, const max_cfg_t *c);
int max_service(max_t *d);
int max_fifo_count(max_t *d, uint32_t *n, uint32_t *ovf);
uint32_t max_unpack(const uint8_t *b);
int max_read_fifo(max_t *d, uint32_t *red, uint32_t *ir, uint32_t maxn, uint32_t *got, uint32_t *lost);
#endif
max103b.c — драйвер: сброс и настройка, флаги INT, указатели FIFO, чтение отсчётов.
/* max103b.c — раздел 103а: драйвер MAX30102 по I2C. Один и тот же файл собирается в прошивку (armclang,
* Cortex-M3) и в стенд на компьютере (gcc): микроконтроллера он не знает, только порт max_port_*. */
#include "max103b.h"
static int xfer_done(max_t *d, int rc)
{
d->xfers++;
if (rc)
d->errors++;
return rc;
}
int max_reg(max_t *d, uint8_t reg, uint8_t v)
{
uint8_t b[2] = {reg, v};
return xfer_done(d, max_port_xfer(d->addr, b, 2, 0, 0));
}
int max_get(max_t *d, uint8_t reg, uint8_t *v)
{
return xfer_done(d, max_port_xfer(d->addr, ®, 1, v, 1));
}
/* сброс, ожидание конца сброса, настройка; режим пишется последним — с него начинаются отсчёты */
int max_init(max_t *d, const max_cfg_t *c)
{
uint8_t v = 0;
int rc;
d->leds = c->mode == MAX_MODE_SPO2 ? 2u : 1u;
d->xfers = d->errors = d->lost = d->alc_ovf = 0;
if ((rc = max_get(d, MAX_PART_ID, &v)) != MAX_OK)
return rc;
if (v != MAX_PART_ID_VAL)
return MAX_E_ID; /* по адресу 0x57 не та микросхема */
if ((rc = max_reg(d, MAX_MODE_CONFIG, MAX_MODE_RESET)) != MAX_OK)
return rc;
for (uint32_t i = 0;; i++) { /* RESET сбрасывается сам */
max_port_delay_us(1000u);
if ((rc = max_get(d, MAX_MODE_CONFIG, &v)) != MAX_OK)
return rc;
if (!(v & MAX_MODE_RESET))
break;
if (i + 1u >= MAX_RESET_TRIES)
return MAX_E_RESET;
}
if ((rc = max_get(d, MAX_INT_STATUS1, &v)) != MAX_OK) /* снять PWR_RDY — INT отпущен */
return rc;
/* без FIFO_ROLLOVER_EN: в полный FIFO новые отсчёты не пишутся, их считает OVF_COUNTER */
if ((rc = max_reg(d, MAX_FIFO_CONFIG, (uint8_t)((c->ave & 7u) << 5 | (c->a_full & 0x0Fu)))) != MAX_OK)
return rc;
if ((rc = max_reg(d, MAX_SPO2_CONFIG, (uint8_t)((c->rge & 3u) << 5 | (c->sr & 7u) << 2 | (c->pw & 3u)))) != MAX_OK)
return rc;
if ((rc = max_reg(d, MAX_LED1_PA, c->led_red)) != MAX_OK)
return rc;
if ((rc = max_reg(d, MAX_LED2_PA, c->led_ir)) != MAX_OK)
return rc;
if ((rc = max_reg(d, MAX_INT_ENABLE1, MAX_INT_A_FULL)) != MAX_OK)
return rc;
return max_reg(d, MAX_MODE_CONFIG, (uint8_t)(c->mode & 7u));
}
/* обработчик INT: прочитать INT_STATUS1 — флаги снимаются, вывод отпускается */
int max_service(max_t *d)
{
int rc = max_get(d, MAX_INT_STATUS1, &d->status);
if (rc == MAX_OK && (d->status & MAX_INT_ALC_OVF))
d->alc_ovf++;
return rc;
}
/* сколько отсчётов ждёт в FIFO: указатели записи и чтения по 5 бит; равны — пусто или полно */
int max_fifo_count(max_t *d, uint32_t *n, uint32_t *ovf)
{
uint8_t reg = MAX_FIFO_WR_PTR, p[3]; /* WR_PTR, OVF_COUNTER, RD_PTR одним чтением */
int rc = xfer_done(d, max_port_xfer(d->addr, ®, 1, p, 3));
if (rc != MAX_OK)
return rc;
*ovf = p[1] & 0x1Fu;
*n = (uint32_t)(p[0] - p[2]) & (MAX_FIFO_DEPTH - 1u);
if (*n == 0u && *ovf != 0u)
*n = MAX_FIFO_DEPTH; /* отсчёты терялись — значит, FIFO полон */
return MAX_OK;
}
/* три байта канала: старший первым, значащие 18 младших бит */
uint32_t max_unpack(const uint8_t *b)
{
return ((uint32_t)b[0] << 16 | (uint32_t)b[1] << 8 | b[2]) & MAX_FULL_SCALE;
}
/* всё, что есть в FIFO, одним чтением: по 3 байта на канал, в SpO2 сначала красный, потом ИК.
* lost — сколько отсчётов не поместилось: они были ПОСЛЕ прочитанных */
int max_read_fifo(max_t *d, uint32_t *red, uint32_t *ir, uint32_t maxn, uint32_t *got, uint32_t *lost)
{
uint8_t reg = MAX_FIFO_DATA, buf[MAX_FIFO_DEPTH * 6u];
uint32_t n, ovf, k = 3u * d->leds;
int rc;
*got = *lost = 0;
if ((rc = max_fifo_count(d, &n, &ovf)) != MAX_OK)
return rc;
if (n > maxn)
n = maxn;
*lost = ovf;
d->lost += ovf;
if (n == 0u)
return MAX_OK;
if ((rc = xfer_done(d, max_port_xfer(d->addr, ®, 1, buf, n * k))) != MAX_OK)
return rc;
for (uint32_t i = 0; i < n; i++) {
red[i] = max_unpack(&buf[i * k]);
ir[i] = d->leds == 2u ? max_unpack(&buf[i * k + 3u]) : 0u;
}
*got = n;
return MAX_OK;
}
ppg103b.h — параметры обработки и её состояние.
/* ppg103b.h — раздел 103а: обработка фотоплетизмограммы целыми числами (у F103 нет FPU).
* Вход — отсчёты красного и ИК каналов MAX30102; выход — удары, ЧСС, отношение R и индекс перфузии.
* Учебный пример: не медицинское изделие, для диагностики не годится. */
#ifndef PPG103B_H
#define PPG103B_H
#include <stdint.h>
#define PPG_DC_SHIFT 5u /* ФВЧ: постоянная составляющая — среднее с весом 1/32 */
#define PPG_MA_N 8u /* ФНЧ: скользящее среднее по 8 отсчётам */
#define PPG_Q 8u /* постоянная составляющая хранится ×256 */
#define PPG_REFRACT_MS 300u /* после удара 300 мс новых не ищем: это 200 ударов в минуту */
#define PPG_IBI_MAX_MS 2000u /* интервал длиннее 2 с — пульс потерян, считать заново */
#define PPG_WARM_MS 1000u /* первую секунду фильтры выходят на режим */
#define PPG_IBI_N 8u /* ЧСС и R — медиана последних 8 ударов */
#define PPG_FINGER_MIN 20000u /* постоянная ИК меньше — пальца нет */
#define PPG_SAT 0x3FF00u /* отсчёт у верха шкалы 18 бит — насыщение */
enum { PPG_F_NOFINGER = 1, PPG_F_SAT = 2 };
typedef struct {
int32_t dc; /* постоянная составляющая ×256 */
int32_t ma[PPG_MA_N], sum; /* окно скользящего среднего */
int32_t y; /* пульсирующая составляющая, перевёрнута: удар — вверх */
int32_t lo, hi; /* размах y с прошлого удара */
} ppg_ch_t;
typedef struct {
ppg_ch_t red, ir;
uint32_t fs, n, pos, start; /* частота; номер отсчёта (с потерянными); место в окне; начало */
uint32_t refract, ibi_max, warm; /* в отсчётах */
int32_t amp, thr; /* высота пиков и порог — её половина */
int above, have_last, started;
uint32_t cand, last; /* кандидат в удары и последний удар — номера отсчётов */
int32_t cand_y;
uint32_t ibi[PPG_IBI_N], rr[PPG_IBI_N], nibi, nr;
uint32_t beats, flags;
uint32_t hr10; /* ЧСС ×10, 0 — оценки нет */
uint32_t r1000; /* R = (AC_red/DC_red)/(AC_ir/DC_ir) ×1000 */
uint32_t pi100; /* AC/DC ИК в сотых долях процента */
} ppg_t;
void ppg_init(ppg_t *p, uint32_t fs);
int ppg_push(ppg_t *p, uint32_t red, uint32_t ir);
void ppg_skip(ppg_t *p, uint32_t n);
uint32_t ppg_median(const uint32_t *v, uint32_t n);
#endif
ppg103b.c — обработка: ФВЧ, ФНЧ, пики, ЧСС, отношение R и индекс перфузии.
/* ppg103b.c — раздел 103а: ФВЧ, ФНЧ, пики с адаптивным порогом и рефрактерным периодом, ЧСС по
* интервалам и отношение R. Тот же файл — в прошивке (armclang, Cortex-M3) и в стенде на компьютере (gcc).
* Учебный пример: не медицинское изделие, для диагностики не годится. */
#include "ppg103b.h"
void ppg_init(ppg_t *p, uint32_t fs)
{
*p = (ppg_t){0};
p->fs = fs;
p->refract = fs * PPG_REFRACT_MS / 1000u;
p->ibi_max = fs * PPG_IBI_MAX_MS / 1000u;
p->warm = fs * PPG_WARM_MS / 1000u;
}
/* медиана до 8 чисел: вставками в копию */
uint32_t ppg_median(const uint32_t *v, uint32_t n)
{
uint32_t s[PPG_IBI_N];
if (n == 0u)
return 0;
for (uint32_t i = 0; i < n; i++) {
uint32_t j = i;
for (; j > 0u && s[j - 1u] > v[i]; j--)
s[j] = s[j - 1u];
s[j] = v[i];
}
return s[n / 2u];
}
/* один канал: ФВЧ (минус медленное среднее), переворот, ФНЧ (среднее по 8) */
static int32_t ch_step(ppg_ch_t *c, uint32_t x, uint32_t pos, int first)
{
int32_t xq = (int32_t)(x << PPG_Q);
if (first)
c->dc = xq;
c->dc += (xq - c->dc) / (1 << PPG_DC_SHIFT);
int32_t ac = c->dc / (1 << PPG_Q) - (int32_t)x; /* кровь поглощает свет: удар — провал, переворачиваем */
c->sum += ac - c->ma[pos];
c->ma[pos] = ac;
c->y = c->sum / (int32_t)PPG_MA_N;
if (c->y < c->lo)
c->lo = c->y;
if (c->y > c->hi)
c->hi = c->y;
return c->y;
}
static void push_last(uint32_t *v, uint32_t *n, uint32_t x)
{
if (*n == PPG_IBI_N) {
for (uint32_t i = 1; i < PPG_IBI_N; i++)
v[i - 1u] = v[i];
(*n)--;
}
v[(*n)++] = x;
}
/* кандидат прошёл: интервал, ЧСС, R и перфузия по размаху с прошлого удара */
static int accept(ppg_t *p)
{
uint32_t d = p->cand - p->last;
if (p->have_last && d < p->refract)
return 0; /* второй горб того же удара или помеха */
p->amp += (p->cand_y - p->amp) / 4;
int32_t ac_r = p->red.hi - p->red.lo, ac_i = p->ir.hi - p->ir.lo;
int32_t dc_r = p->red.dc / (1 << PPG_Q), dc_i = p->ir.dc / (1 << PPG_Q);
if (p->have_last && d <= p->ibi_max) {
push_last(p->ibi, &p->nibi, d);
if (ac_i > 0 && ac_r > 0 && dc_r > 0 && dc_i > 0) {
push_last(p->rr, &p->nr, (uint32_t)((uint64_t)ac_r * (uint32_t)dc_i * 1000u / ((uint64_t)ac_i * (uint32_t)dc_r)));
p->pi100 = (uint32_t)((uint64_t)ac_i * 10000u / (uint32_t)dc_i);
}
if (p->nibi >= 3u) {
uint32_t m = ppg_median(p->ibi, p->nibi);
p->hr10 = (600u * p->fs + m / 2u) / m;
p->r1000 = ppg_median(p->rr, p->nr);
}
} else {
p->nibi = p->nr = 0; /* пауза больше 2 с: всё сначала */
p->hr10 = p->r1000 = 0;
}
p->last = p->cand;
p->have_last = 1;
p->beats++;
p->red.lo = p->red.hi = p->red.y;
p->ir.lo = p->ir.hi = p->ir.y;
return 1;
}
/* один отсчёт; 1 — найден удар (его номер — p->last) */
int ppg_push(ppg_t *p, uint32_t red, uint32_t ir)
{
uint32_t idx = p->n++;
int first = !p->started;
int32_t y = ch_step(&p->ir, ir, p->pos, first);
(void)ch_step(&p->red, red, p->pos, first);
p->pos = (p->pos + 1u) % PPG_MA_N;
if (first) {
p->started = 1;
p->start = idx;
}
p->flags = (red >= PPG_SAT || ir >= PPG_SAT) ? PPG_F_SAT : 0u;
if ((uint32_t)(p->ir.dc / (1 << PPG_Q)) < PPG_FINGER_MIN) {
p->flags |= PPG_F_NOFINGER; /* пальца нет: ни ударов, ни оценок */
p->nibi = p->nr = 0;
p->hr10 = p->r1000 = 0;
p->above = p->have_last = 0;
return 0;
}
if (idx - p->start < p->warm) {
if (y > p->amp)
p->amp = y; /* первая оценка высоты пиков */
return 0;
}
p->amp -= p->amp / 256; /* высота пиков забывается за несколько секунд */
p->thr = p->amp / 2;
if (y > p->thr && y > 0) {
if (!p->above || y > p->cand_y) {
p->cand = idx;
p->cand_y = y;
}
p->above = 1;
return 0;
}
if (!p->above)
return 0;
p->above = 0; /* спуск под порог: пик позади */
return accept(p);
}
/* потерянные отсчёты (OVF_COUNTER): время идёт, фильтры стоят */
void ppg_skip(ppg_t *p, uint32_t n)
{
p->n += n;
}
fw103b.c — прошивка STM32F103: I2C1 на 400 кГц, приём по способу 2, INT на PB8 через EXTI8.
/* fw103b.c — раздел 103а: MAX30102 на STM32F103 (BluePill), 72 МГц, APB1 = 36 МГц.
* I2C1: PB6 — SCL, PB7 — SDA (открытый сток, 400 кГц). INT модуля — на PB8 (допуск 5 В, вход с подтяжкой,
* EXTI8 по спаду). SpO2: 100 отсчётов в секунду, 411 мкс (18 бит), диапазон 4096 нА, ток 7,2 мА, прерывание
* при 17 отсчётах в FIFO. Результат — в переменных hr10, r1000, pi100 (смотреть отладчиком).
* Учебный пример: не медицинское изделие, для диагностики не годится. Тактирование до 72 МГц — как в разделе 6. */
#include "stm32f10x.h"
#include "max103b.h"
#include "ppg103b.h"
#define CPU_HZ 72000000u
#define APB1_MHZ 36u
#define I2C_TIMEOUT_US 1000u /* дольше любого флага на 400 кГц */
static max_t dev = {.addr = MAX_ADDR};
static ppg_t ppg;
static volatile uint8_t irq;
volatile uint32_t hr10, r1000, pi100, flags, bus_errors, lost;
static int expired(uint32_t t0, uint32_t us)
{
return DWT->CYCCNT - t0 >= us * (CPU_HZ / 1000000u);
}
void max_port_delay_us(uint32_t us)
{
uint32_t t0 = DWT->CYCCNT;
while (!expired(t0, us)) {
}
}
static void i2c1_init(void)
{
RCC->APB2ENR |= RCC_APB2ENR_IOPBEN | RCC_APB2ENR_AFIOEN;
RCC->APB1ENR |= RCC_APB1ENR_I2C1EN;
/* PB6, PB7 — альтернативная функция, открытый сток, 50 МГц (1111); PB8 — вход с подтяжкой (1000) */
GPIOB->CRL = (GPIOB->CRL & 0x00FFFFFFu) | 0xFF000000u;
GPIOB->CRH = (GPIOB->CRH & ~0xFu) | 0x8u;
GPIOB->BSRR = 1u << 8;
I2C1->CR1 = 0; /* настраивать при PE = 0 */
I2C1->CR2 = APB1_MHZ;
I2C1->CCR = I2C_CCR_FS | 30u; /* DUTY = 0: 36 МГц / (3 × 30) = 400 кГц */
I2C1->TRISE = APB1_MHZ * 300u / 1000u + 1u; /* 300 нс × 36 МГц = 10,8 → 10 + 1 = 11 */
I2C1->CR1 = I2C_CR1_PE;
}
static int sr1_wait(uint16_t mask)
{
uint32_t t0 = DWT->CYCCNT;
while (!(I2C1->SR1 & mask)) {
if (I2C1->SR1 & I2C_SR1_AF)
return MAX_E_NACK; /* адрес или байт не подтверждён */
if (expired(t0, I2C_TIMEOUT_US))
return MAX_E_BUS;
}
return MAX_OK;
}
static int fail(int rc)
{
I2C1->CR1 |= I2C_CR1_STOP;
I2C1->SR1 = (uint16_t)~I2C_SR1_AF; /* AF снимается записью нуля */
I2C1->CR1 |= I2C_CR1_ACK;
return rc;
}
/* приём nrx байт после повторного старта: 1 байт — RM0008, с. 764; больше двух — способ 2, с. 762–763 */
static int rx_bytes(uint8_t *rx, uint32_t nrx)
{
int rc;
uint32_t i = 0;
if (nrx == 1u) {
I2C1->CR1 &= (uint16_t)~I2C_CR1_ACK; /* ACK снять до сброса ADDR */
(void)I2C1->SR2;
I2C1->CR1 |= I2C_CR1_STOP;
if ((rc = sr1_wait(I2C_SR1_RXNE)) != MAX_OK)
return rc;
rx[0] = (uint8_t)I2C1->DR;
I2C1->CR1 |= I2C_CR1_ACK;
return MAX_OK;
}
(void)I2C1->SR2; /* ADDR снят, ACK = 1 */
for (; i + 3u < nrx; i++) {
if ((rc = sr1_wait(I2C_SR1_RXNE)) != MAX_OK)
return rc;
rx[i] = (uint8_t)I2C1->DR;
}
if ((rc = sr1_wait(I2C_SR1_BTF)) != MAX_OK) /* осталось три: N−2 в DR, N−1 в сдвиговом, SCL прижат */
return rc;
I2C1->CR1 &= (uint16_t)~I2C_CR1_ACK;
rx[i++] = (uint8_t)I2C1->DR; /* N−2; пошёл приём N с NACK */
if ((rc = sr1_wait(I2C_SR1_BTF)) != MAX_OK)
return rc;
I2C1->CR1 |= I2C_CR1_STOP;
rx[i++] = (uint8_t)I2C1->DR; /* N−1 */
if ((rc = sr1_wait(I2C_SR1_RXNE)) != MAX_OK)
return rc;
rx[i] = (uint8_t)I2C1->DR; /* N */
I2C1->CR1 |= I2C_CR1_ACK;
return MAX_OK;
}
int max_port_xfer(uint8_t addr7, const uint8_t *tx, uint32_t ntx, uint8_t *rx, uint32_t nrx)
{
int rc;
uint32_t t0 = DWT->CYCCNT;
if (nrx == 2u)
return MAX_E_BUS; /* драйвер читает 1, 3 или 6·n байт */
while (I2C1->SR2 & I2C_SR2_BUSY) { /* SDA держит ведомый — раздел 17.6 */
if (expired(t0, I2C_TIMEOUT_US))
return MAX_E_BUS;
}
I2C1->CR1 |= I2C_CR1_ACK;
I2C1->CR1 |= I2C_CR1_START;
if ((rc = sr1_wait(I2C_SR1_SB)) != MAX_OK)
return fail(rc);
I2C1->DR = (uint8_t)(addr7 << 1);
if ((rc = sr1_wait(I2C_SR1_ADDR)) != MAX_OK)
return fail(rc);
(void)I2C1->SR2;
for (uint32_t i = 0; i < ntx; i++) {
if ((rc = sr1_wait(I2C_SR1_TXE)) != MAX_OK)
return fail(rc);
I2C1->DR = tx[i];
}
if ((rc = sr1_wait(I2C_SR1_BTF)) != MAX_OK)
return fail(rc);
if (!nrx) {
I2C1->CR1 |= I2C_CR1_STOP;
return MAX_OK;
}
I2C1->CR1 |= I2C_CR1_START; /* повторный старт: номер регистра уже передан */
if ((rc = sr1_wait(I2C_SR1_SB)) != MAX_OK)
return fail(rc);
I2C1->DR = (uint8_t)(addr7 << 1 | 1u);
if ((rc = sr1_wait(I2C_SR1_ADDR)) != MAX_OK)
return fail(rc);
if ((rc = rx_bytes(rx, nrx)) != MAX_OK)
return fail(rc);
return MAX_OK;
}
/* INT: спад на PB8 — в FIFO 17 отсчётов */
void EXTI9_5_IRQHandler(void)
{
EXTI->PR = 1u << 8;
irq = 1;
}
static void irq_init(void)
{
AFIO->EXTICR[2] = (AFIO->EXTICR[2] & ~0xFu) | 0x1u; /* EXTI8 ← PB8 */
EXTI->FTSR |= 1u << 8;
EXTI->IMR |= 1u << 8;
NVIC_EnableIRQ(EXTI9_5_IRQn);
}
static const max_cfg_t cfg = {.mode = MAX_MODE_SPO2, .sr = 1, .pw = 3, .rge = 1, .ave = 0, .a_full = 15,
.led_red = 0x24, .led_ir = 0x24};
int main(void)
{
static uint32_t red[MAX_FIFO_DEPTH], ir[MAX_FIFO_DEPTH];
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
DWT->CYCCNT = 0;
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
i2c1_init();
irq_init();
while (max_init(&dev, &cfg) != MAX_OK)
max_port_delay_us(100000u); /* нет ответа: провод, адрес, питание, уровни */
ppg_init(&ppg, 100u);
for (;;) {
uint32_t got = 0, n_lost = 0;
if (!irq && (GPIOB->IDR & (1u << 8)))
continue; /* INT в единице и спада не было — ждать */
irq = 0;
if (max_service(&dev) != MAX_OK || max_read_fifo(&dev, red, ir, MAX_FIFO_DEPTH, &got, &n_lost) != MAX_OK) {
bus_errors++;
continue;
}
for (uint32_t i = 0; i < got; i++)
(void)ppg_push(&ppg, red[i], ir[i]);
ppg_skip(&ppg, n_lost); /* потерянные — после прочитанных */
hr10 = ppg.hr10;
r1000 = ppg.r1000;
pi100 = ppg.pi100;
flags = ppg.flags;
lost = dev.lost;
}
}
Дальше: раздел 103б «Датчик цвета TCS34725 по I2C: каналы RGBC, время интегрирования и усиление, баланс белого, цветовая температура и освещённость» — цвет вместо пульса: четыре канала TCS34725 по I2C, баланс белого и узнавание карточек, люксы и кельвины по аппнотке DN40.