Перейти к основному содержимому

Сигналы из формул — кардиограмма, нарисованная арифметикой

Почти все колонки в конфиге тянутся: имя из пака, число из диапазона, дата из окна. Сигнал устроен иначе. Его значение в каждый момент — не выбор, оно следует из того, где вы находитесь во времени. Это работа для <gen type="formula">, и это единственная конструкция, которая выдаёт форму, а не мешок значений.

Этот гайд собирает синтетическую электрокардиограмму: CSV из двух колонок, который при построении графика рисует узнаваемое сердцебиение. Тот же приём годится для любого повторяющегося измерения — вибрации станка, суточной кривой температуры, почасового трафика на дороге.

Сердцебиение — это пять колоколов

Один удар сердца — пять горбов подряд, и каждый горб одной и той же формы: колокол, высокий в середине и сходящий на нет по краям. Колокол описывается тремя числами: какой он высоты, где стоит его центр и насколько широко расходится:

высота * exp(-pow((положение - центр) / ширина, 2))

exp и pow — обычные функции выражений, и во всех пяти реализациях они считают одинаково. Меняете центр — горб едет по оси времени. Меняете ширину — становится уже или площе. Больше ничего не нужно.

Время и удар, которому оно принадлежит

Две колонки превращают счётчик строк в часы. Берём 250 отсчётов в секунду, как настоящий монитор, — значит на строку приходится 4 миллисекунды:

<sequence name="T"><gen type="formula" expr="(_count - 1) * 4"/></sequence>
<sequence name="N"><gen type="formula" expr="floor(T / 1000)"/></sequence>

_count — номер строки, поэтому T — время в миллисекундах. floor(T / 1000) — это номер удара: целую секунду он держится на нуле, потом становится единицей, потом двойкой.

Вторая колонка и есть самая полезная. Всё, что считается от N, стоит на месте всю длину удара и меняется только на границе — благодаря этому один удар выходит непохожим на соседний, а горбы внутри него не дёргаются.

Где удар начинается

Удар вовсе не обязан начинаться на круглой секунде:

<sequence name="Onset"><gen type="formula" expr="N * 1000 + 45 * sin(N * 1.7)"/></sequence>
<sequence name="Phase"><gen type="formula" expr="T - Onset"/></sequence>

Onset — момент начала удара N, сдвинутый на 45 миллисекунд в ту или другую сторону от круглой секунды. Phase — насколько глубоко текущая строка сидит внутри своего удара, и каждый колокол ставится относительно Phase, а не относительно часов.

На вдохе сердце ускоряется, на выдохе замедляется, поэтому промежуток между ударами всё время гуляет на несколько процентов. Запись без такого разброса читается как рисунок сердцебиения, а не как его измерение.

Пять колоколов, с настоящими числами

<sequence name="P"><gen type="formula" expr="0.12 * exp(-pow((Phase - 200) / 22, 2))"/></sequence>
<sequence name="Q"><gen type="formula" expr="-0.16 * exp(-pow((Phase - 372) / 10, 2))"/></sequence>
<sequence name="R"><gen type="formula" expr="Amp * exp(-pow((Phase - 400) / 8, 2))"/></sequence>
<sequence name="S"><gen type="formula" expr="-0.28 * exp(-pow((Phase - 428) / 12, 2))"/></sequence>
<sequence name="TW"><gen type="formula" expr="0.35 * exp(-pow((Phase - 620) / 45, 2))"/></sequence>
КолоколВысота, мВЦентр, мсШирина, мсЧто это
P0.1220022сокращаются предсердия
Q−0.1637210провал перед главным пиком
R1.204008срабатывают желудочки
S−0.2842812провал после него
T0.3562045желудочки восстанавливаются

Два числа задают весь характер линии. У R ширина восемь миллисекунд при ударе длиной в тысячу — оттого пик почти отвесный. У T она 45 — оттого последний горб выходит длинным и низким.

Пять бледных колоколов и более тёмная линия их суммы на протяжении одного удара
Один удар в разборе. Все значения получены запуском конфига, приведённого ниже.
  • пять колоколов, каждый — отдельная колонка
  • их сумма, единственная колонка, которая попадает в файл
  • AP — сокращаются предсердия
  • BQ — провал перед пиком
  • CR — срабатывают желудочки
  • DS — провал после
  • ET — желудочки восстанавливаются

По горизонтали — миллисекунды внутри одного удара, по вертикали — милливольты. Пять из этих колонок никогда не печатаются. Последовательность, которую не упоминает ни один <block>, всё равно участвует в вычислениях — так конфиг носит с собой собственные черновые выкладки.

Как оживить

Ещё две колонки не дают каждому удару стать копией предыдущего:

<sequence name="Amp"><gen type="formula" expr="1.20 + 0.07 * sin(N * 2.3)"/></sequence>
<sequence name="Drift"><gen type="formula" expr="0.05 * sin(_count / 95)"/></sequence>
<sequence name="Noise"><gen type="number" distribution="normal" mean="0" sd="0.012" decimals="4"/></sequence>

Amp меняет высоту пика от удара к удару, потому что читает N. Drift медленно качает изолинию — так поднимающаяся и опускающаяся грудная клетка двигает электроды. Noise — единственная тянутая колонка на этой странице: нормальная, крошечная и своя в каждой строке, ровно то, что добавляет настоящий датчик.

Пять секунд линии на клетчатой бумаге с пунктирами на круглых секундах
Пять секунд. Пики уходят от отметок секунд и возвращаются обратно.
  • круглые секунды
  • сгенерированная линия
  • Aотметка третьей секунды
  • Bпик, который до неё не дотянул

Если измерить по десяти секундам этого вывода, промежуток между пиками гуляет от 932 до 1068 миллисекунд — пульс бродит между 56 и 64 ударами в минуту. Это как раз тот диапазон, который показывает взрослый человек в покое.

Живая — и по-прежнему воспроизводимая

В этом разбросе нет ничего случайного. sin(N * 1.7) — арифметика от номера удара, поэтому один и тот же сид даёт один и тот же файл, байт в байт, в любой реализации. Неровность здесь — то, что вы конструируете, а не то, над чем теряете контроль.

Конфиг целиком

<tdc>
<env count="2500" seed="ecg">
<sequence name="T"><gen type="formula" expr="(_count - 1) * 4"/></sequence>
<sequence name="N"><gen type="formula" expr="floor(T / 1000)"/></sequence>
<sequence name="Onset"><gen type="formula" expr="N * 1000 + 45 * sin(N * 1.7)"/></sequence>
<sequence name="Phase"><gen type="formula" expr="T - Onset"/></sequence>
<sequence name="Amp"><gen type="formula" expr="1.20 + 0.07 * sin(N * 2.3)"/></sequence>

<sequence name="P"><gen type="formula" expr="0.12 * exp(-pow((Phase - 200) / 22, 2))"/></sequence>
<sequence name="Q"><gen type="formula" expr="-0.16 * exp(-pow((Phase - 372) / 10, 2))"/></sequence>
<sequence name="R"><gen type="formula" expr="Amp * exp(-pow((Phase - 400) / 8, 2))"/></sequence>
<sequence name="S"><gen type="formula" expr="-0.28 * exp(-pow((Phase - 428) / 12, 2))"/></sequence>
<sequence name="TW"><gen type="formula" expr="0.35 * exp(-pow((Phase - 620) / 45, 2))"/></sequence>

<sequence name="Drift"><gen type="formula" expr="0.05 * sin(_count / 95)"/></sequence>
<sequence name="Noise"><gen type="number" distribution="normal" mean="0" sd="0.012" decimals="4"/></sequence>

<sequence name="Sec"><gen type="formula" expr="T / 1000" decimals="3"/></sequence>
<sequence name="MV"><gen type="formula" expr="P + Q + R + S + TW + Drift + Noise" decimals="4"/></sequence>

<before><line><data>seconds,millivolts</data></line></before>
</env>

<block>
<line><data>${{Sec}},${{MV}}</data></line>
</block>
</tdc>
./run ecg.tdc
seconds,millivolts
0.000,0.0158
0.004,-0.0164
0.008,-0.0065
0.012,0.0089
0.016,-0.0052
0.020,0.0121

Десять секунд записи — это 2 500 строк. Первые строки приходятся на ровный участок до первого удара: именно так линия и выглядит в промежутках между сокращениями. Загрузите файл в таблицу, постройте колонку B против колонки A линией — и форма появится.

Что здесь можно крутить

Что менятьГдеРезультат
Частоту пульса1000 в Onset и N600 даст 100 ударов в минуту
Частоту дискретизации4 в T2 даст 500 отсчётов в секунду
Силу пика1.20 в Ampвысота R в милливольтах
Ровность ритма45 в Onset0 сделает каждый удар ровно в секунду
Качество датчикаsd у Noiseбольше — запись шумнее

Поставить 45 в 0 стоит хотя бы раз. Линия остаётся правильной и при этом начинает выглядеть искусственной — видно, насколько «реалистичность» держится на неровности, а не на форме.

Где это ещё пригодится

Приём вообще не про сердце. Всё, что повторяется с периодом, слегка меняется от цикла к циклу и несёт шум, собирается из тех же трёх частей: floor нумерует циклы, фаза внутри цикла, а на неё ставятся колокола или синусы. Вибрация станка, суточная кривая температуры и почасовой подсчёт трафика строятся точно так же.

Если колонка вместо этого должна накапливаться по строкам — смотрите running. Если нужна одна цифра, посчитанная по всему прогону, — stat.