Сигналы из формул — кардиограмма, нарисованная арифметикой
Почти все колонки в конфиге тянутся: имя из пака, число из диапазона, дата из окна.
Сигнал устроен иначе. Его значение в каждый момент — не выбор, оно следует из того, где вы
находитесь во времени. Это работа для
<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>
| Колокол | Высота, мВ | Центр, мс | Ширина, мс | Что это |
|---|---|---|---|---|
| P | 0.12 | 200 | 22 | сокращаются предсердия |
| Q | −0.16 | 372 | 10 | провал перед главным пиком |
| R | 1.20 | 400 | 8 | срабатывают желудочки |
| S | −0.28 | 428 | 12 | провал после него |
| T | 0.35 | 620 | 45 | желудочки восстанавливаются |
Два числа задают весь характер линии. У 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>
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 и N | 600 даст 100 ударов в минуту |
| Частоту дискретизации | 4 в T | 2 даст 500 отсчётов в секунду |
| Силу пика | 1.20 в Amp | высота R в милливольтах |
| Ровность ритма | 45 в Onset | 0 сделает каждый удар ровно в секунду |
| Качество датчика | sd у Noise | больше — запись шумнее |
Поставить 45 в 0 стоит хотя бы раз. Линия остаётся правильной и при этом начинает
выглядеть искусственной — видно, насколько «реалистичность» держится на неровности, а не на
форме.
Где это ещё пригодится
Приём вообще не про сердце. Всё, что повторяется с периодом, слегка меняется от цикла к
циклу и несёт шум, собирается из тех же трёх частей: floor нумерует циклы, фаза внутри
цикла, а на неё ставятся колокола или синусы. Вибрация станка, суточная кривая температуры
и почасовой подсчёт трафика строятся точно так же.
Если колонка вместо этого должна накапливаться по строкам — смотрите
running. Если нужна одна цифра, посчитанная по всему
прогону, — stat.