Цикл «Данные без противоречий» — про TDCV2, открытый конструктор тестовых данных, который я пишу сам. Работает он так: вы описываете конфигом, какой файл вам нужен, а конструктор порождает его целиком — от двух строк до сотен тысяч. Нужно это, когда надо наполнить базу, проверить отчёт или показать демо, а настоящие данные брать нельзя. От генераторов случайных значений он отличается одним: следит, чтобы данные не противоречили сами себе — про это весь цикл.

○ Часть 1 — Связи между полями
○ Часть 2 — Свободный формат вывода
○ Часть 3 — Справочники
● Часть 4 — Форма данных рисунком

В первой части я связывал поля внутри строки, во второй собирал из значений файл любого вида, в третьей связывал уже не поля, а записи целиком. Сегодня про самую наглядную штуку из всего, что в генераторе есть, и объяснить её проще всего на примере.

Допустим, нужен суточный профиль нагрузки на сервис: сколько запросов в секунду приходит в каждый час. Ночью тихо, утром первый подъём, днём просадка, вечером пик выше утреннего. Форма известна любому, кто хоть раз смотрел на графики продакшена.

А теперь попробуйте получить такое из готовых распределений. Нормальное — один симметричный колокол. Экспоненциальное — затухающий хвост. Двух горбов разной высоты нет ни в одном из них, и никакой подбор параметров их оттуда не достанет.

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

Вот её и нарисуем:

Это всё описание. Теперь просим двадцать четыре строки — по одной на час:

00:00   536      08:00  1711      16:00  2145
01:00   490      09:00  1826      17:00  2539
02:00   443      10:00  1601      18:00  2933
03:00   397      11:00  1376      19:00  2587
04:00   350      12:00  1300      20:00  2089
05:00   387      13:00  1371      21:00  1612
06:00   828      14:00  1442      22:00  1186
07:00  1270      15:00  1751      23:00   760

Ночной минимум пришёлся на четыре утра, вечерний пик — на шесть. Ни распределения, ни формулы, ни единого подобранного коэффициента: я нарисовал линию, и данные легли по ней.

Идея

Идея простая: мы даём генератору картинку. Горизонталь — это номер строки, от первой до последней. Вертикаль — значение. Форма растягивается на весь прогон: хоть на сорок строк, хоть на четыре миллиона.

В конфиге это выглядит так:

<gen type="pattern" src="chart.svg" y_range="0..100"/>

Всё. Никакого списка распределений, из которого надо выбрать ближайшее.

Заводить под каждую форму файл, впрочем, не обязательно, и рисовать мышкой тоже. Любая ломаная — это несколько точек, соединённых отрезками, а точки можно просто перечислить в конфиге. Фигура выйдет та же самая: движку всё равно, откуда пришли её точки — из редактора или из строчки текста. Два входа, один результат.

Нарисуем самое простое, что бывает — горку:

Точек у неё три: у левого края внизу, вершина ровно посередине наверху, у правого края снова внизу. Так их и перечисляем — парами через пробел, сначала сдвиг вправо, потом высота:

<gen type="pattern" points="0,0 50,100 100,0" y_range="0..100"/>

Первое число в паре отсчитывается вдоль прогона, от первой строки к последней. Второе — по высоте холста: у перечисленных точек холст есть по умолчанию, от 0 до 100, а y_range — это то, чем становятся его пол и потолок. Никакого файла тут нет, а горка есть. Одиннадцать строк по нему:

0  20  40  60  80  100  80  60  40  20  0

Ровно так же записана и кривая нагрузки из начала статьи — только точек в ней восемь вместо трёх:

<gen type="pattern"
     points="0,12 21,4 37,62 50,38 62,45 79,100 90,55 100,20"
     y_range="200..3000"/>

Строк мы взяли одиннадцать не случайно. Замеры стоят равномерно, и при нечётном их числе средний попадает ровно в середину картинки — то есть в вершину. Попросите десять строк, и вершина окажется между двумя замерами:

10 строк:  0  22  44  67  89  89  67  44  22  0
11 строк:  0  20  40  60  80 100  80  60  40  20  0
12 строк:  0  18  36  55  73  91  91  73  55  36  18  0

Обратите внимание: сотни в чётных случаях у нас нет вообще, максимум 89 и 91. Это не округление, а прямое следствие того, где стоят замеры. Ниже я разберу это подробнее, потому что на этом легко обжечься.

Вещь, о которую легко споткнуться. Числа в рисунке движок не берёт как значения. У рисунка есть холст, и y_range — это то, чем становятся его пол и потолок. Весь вопрос в том, откуда холст взялся.

У файла его нет: чужой SVG нигде не говорит, где у него ноль. Поэтому движок меряет сам рисунок — низ и верх чернил и есть холст. Нарисуете линию, гуляющую между 20 и 92, поставите y_range="0..100" — получите значения от 0 до 100, а не от 20 до 92.

У перечисленных точек холст есть с самого начала, от 0 до 100. Поэтому points="0,40 50,40 100,40" даёт сорок в каждой строке и никуда не растягивается: сорок на холсте в сто — это и есть сорок. И холст только растёт, никогда не сжимается: напишете 400 — он станет 0…400, а линия окажется на потолке. Так картинка, выгруженная из редактора с холстом в 480 пикселей, ложится в диапазон целиком, а не расплющивается о крышу.

Звучит как ограничение, но это ровно то, чем рисунок и является. Файл, выгруженный из чужого редактора, несёт форму — и больше ничего. Ни единиц, ни начала отсчёта, ни даже того, где у него верх. Измерить в нём можно только две вещи: свою самую низкую точку и свою самую высокую. Поэтому по умолчанию рисунок и растягивается на весь y_range: это не догадка движка, а объявленное соглашение, которое ничего не выдумывает.

А если растягивать на весь диапазон не надо — есть fit. Он говорит, во что превратятся эти две крайние точки. Беру кривую из файла, диапазон 200…300, и сажаю её в узкую полосу:

<gen type="pattern" src="krivaya.svg" y_range="200..300" fit="255..297"/>
без fit:      200  238  275  246  217  258  300  271  242  254  267
fit 255..297: 255  271  287  274  262  280  297  285  273  278  283

Без fit низ рисунка сел на 200, верх — на 300. С ним — на 255 и 297, и наружу не вышло ничего. В каких координатах был нарисован файл, движку всё равно: хоть 0…1000, хоть от минус пяти до трёх миллионов. Форма выживает, а куда её посадить — объявляете вы.

Что происходит между точками?

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

linear:  0  20  40  60  80  100  80  60  40  20  0
smooth:  0  23  50  74  93  100  93  74  50  23  0
step:    0   0   0   0   0  100  100  100  100  100  0

linear идёт по ломаной ровно так, как она нарисована: шаг всю дорогу один и тот же, по двадцать. smooth скругляет углы — шаги у него неровные, 23, 27, 24, 19, 7: к вершине линия подходит всё медленнее, а от неё так же плавно отваливается. Важно, что подняться выше нарисованного она при этом не может, поэтому фантомного пика из скругления не вырастет. step держит значение точки до самой следующей — получается лестница.

Посмотрите, чем кончается step: нулём, как и два других. Ступенька держит значение в полосе справа от своей точки, а у последней точки полосы справа нет — рисунок на ней кончается. Поэтому последняя строка читает её там, где её видят все три режима: на самом правом краю. Три разных способа пройти путь сходятся на последнем, что вы нарисовали.

Выбирать тут просто. linear годится, пока читатель ваших данных не смотрит на скорость изменения: ровный шаг никого не смутит в отчёте, но выдаст синтетику на графике. smooth берут, когда данные изображают что‑то физическое — температуру, нагрузку, износ: у таких величин не бывает мгновенной смены темпа. step нужен, когда величина и правда держится, а потом скачет: тариф, курс на день, режим работы.

Как движок читает картинку

Вот это место мне нравится больше всего, потому что решение до неприличия простое.

Движок не пытается понять, что нарисовано — линия, полоса, клякса. Он идёт по картинке колонка за колонкой и делает два замера: сверху вниз до первой встреченной краски и снизу вверх до первой встреченной краски. Всё. Больше он ничего не смотрит.

  • Замеры встретились в одной точке — значит в этой колонке одна линия. Строка получает ровно это значение, без всякой случайности.

  • Замеры разошлись — значит это полоса. Строка получает случайное значение между краями, зафиксированное сидом.

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

Раз правило про два замера работает на каждой колонке независимо, одна и та же картинка может быть и линией, и полосой — в разных местах. Нарисуем линию, которая идёт ровно, а потом раздваивается:

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

Запустим её с двумя разными сидами. Первая половина совпадает до последней цифры, вторая — нет:

сид A:  50  50  50  50  50  50  45  48  63  25  46
сид B:  50  50  50  50  50  50  56  59  58  75  38

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

Фантазируем разброс

Рисуем две границы, и данные ложатся между ними. Где коридор широкий — разброс широкий, где сужается — сужается и разброс:

<gen type="pattern"
     upper="0,85 25,75 50,64 75,77 100,87"
     lower="0,35 25,45 50,46 75,33 100,13"
     y_range="0..100"/>

Обычно разброс мы задаём числом: плюс‑минус столько‑то. А здесь он сам меняется по ходу выборки, потому что вы его нарисовали. Утром данные разлетаются широко, к середине дня собираются в узкую полосу, к вечеру расходятся опять — и это одна картинка, а не три правила.

А вот хороший пример, где это пригодится.

Нужны показания вибродатчика на подшипнике. Пока подшипник целый, замеры кучные: около двухсот единиц, туда‑сюда на десяток. По мере износа среднее остаётся почти тем же, а вот разброс растёт — замеры начинают скакать. Именно по расширению разброса такие вещи и ловят, поэтому в тестовых данных оно должно быть.

Формулой это описывается долго: нужно среднее, нужна дисперсия, и дисперсия должна быть функцией от номера строки. Рисунком — это просто коридор, который расширяется к правому краю:

<gen type="pattern"
     upper="0,52 40,54 60,60 80,74 100,92"
     lower="0,48 40,46 60,40 80,26 100,8"
     y_range="0..400"/>
200 203 196 190 190 187 198 203 192 201
182 190 237 188 136 180 288  83 215 163

Первая десятка замеров укладывается в размах шестнадцати единиц, последняя — в двести пять. Среднее при этом не уехало. Я не задавал ни одной формулы дисперсии — я нарисовал воронку.

Рисовать обе границы вручную ради небольшого дрожания — скучно. Поэтому есть spread: он берёт нарисованную линию как центр и раскидывает каждую строку на плюс‑минус N вокруг неё.

<gen type="pattern" src="ramp.svg" y_range="0..100" spread="6"/>
spread 0:   0  10  20  30  40  50  60  70  80  90  100
spread 6:  -6  12  14  34  45  49  54  72  75  89  103

Форма при этом не меняется, тренд остаётся ровно тем, что нарисован. И разброс тоже детерминированный: тот же сид даёт тот же самый рисунок точек.

Посмотрите на края: -6 и 103. Плюс‑минус шесть считается от линии, а не подрезается по y_range, поэтому у самых краёв значения выходят за диапазон.

Строк меньше, чем деталей на картинке

Картинка и прогон никак не связаны размерами: рисунок может быть 240 пикселей шириной, а строк мы попросим четыре миллиона. Интересно, что происходит в обратную сторону — когда строк меньше, чем деталей.

Тут может быть непонятка, потому что ждут обычно другого. Строка не усредняет свой кусок картинки. Она берёт значение в одной точке — там, где эта строка стоит на горизонтали.

Давайте нарисуем узкий пик шириной в два процента ровно посередине:

А теперь попросим десять строк:

0  0  0  0  0  0  0  0  0  0

Пика нет вообще. На десяти строках замеры стоят через десять процентов ширины, а пик занимает два — он просто не попал ни в один замер. На ста одной строке тот же пик берётся точно, и максимум равен ста.

Это не потеря измерения, а то, что значит «дай мне десять строк». Важно тут другое: правило простое, и вы можете посмотреть на свой рисунок заранее и сказать, что из него выйдет. Нужны детали — рисуйте крупнее или просите больше строк.

Что ещё способна дать нам картинка?

До сих пор мы задавали картинке один вопрос: какое значение достанется вот этой строке? Значения идут вдоль линии, по порядку, как след от карандаша.

А вот и не очевидное! Если взять эти значения и, забыв про порядок, построить по ним гистограмму — формы картинки в ней не будет. Возьмите ту же горку, сгенерируйте шесть тысяч строк, посчитайте, как часто выпало каждое число, — выйдет ровная полка. Это не поломка: сигнал проходит через каждую высоту примерно одинаково часто, поэтому куча его значений и не похожа на рисунок.

Но картинке можно задать и другой вопрос: как часто должно встречаться вот это значение? Это mode="density", и там оси меняются местами. По горизонтали теперь не номер строки, а само значение. Высота рисунка — это частота.

<gen type="pattern" points="0,0 50,100 100,0"
     y_range="0..100" mode="density"/>

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

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

значение   сколько раз выпало из 60 000
   0- 9   ####                                      1053
  10-19   ############                              3398
  20-29   ######################                    5933
  30-39   ##############################            8303
  40-49   #######################################  10760
  50-59   ######################################## 10956
  60-69   ###############################           8552
  70-79   ######################                    6064
  80-89   ##############                            3722
  90-99   #####                                     1259

Столбики поднимаются к середине и спадают к краям — то есть повторяют нарисованную горку. Никакого распределения я не выбирал, я нарисовал две линии.

То есть вы рисуете закон распределения мышкой: не выбираете из списка нормальное, пуассоновское или ещё какое‑то, а рисуете ровно ту форму, которая нужна.

И высота работает пропорционально, а не просто «тут повыше, тут пониже». Нарисуем два горба одинаковой ширины, правый вдвое выше:

Еще пара вещей, которые стоит знать про этот режим. Ноль — это низ картинки: там, где кривая опустилась до основания, значение не выпадет никогда, поэтому по краям нужного диапазона кривую надо приводить к нулю. Порядок значений случайный — у распределения порядка и нет. И spread здесь запрещён: разброс уже нарисован, добавлять к нему ещё один бессмысленно.

Чем рисовать, и во что это обходится

Да чем угодно. Читаются два формата, и оба превращаются внутри в одно и то же — в векторный контур.

  • SVG — берётся настоящая геометрия: path, polyline, polygon, line, а с ними и замкнутые фигуры — rect, circle, ellipse. Кривые Безье проходятся как нарисованы, transform применяется. Illustrator, Figma, Inkscape — экспортировали и доставте в генератор.

  • PNG — если фон прозрачный, рисунком считается всё непрозрачное. Если фон сплошной, краской считается всё тёмное. То есть скриншот биржевого графика, обрезанный по области с кривой, работает как есть.

У порога есть смысл, когда штрих слабый. Краской считается всё, что темнее ink_threshold — по умолчанию это половина шкалы. Поднимете порог к единице — в дело пойдут светло‑серые и размытые края, опустите к нулю — останутся только почти чёрные линии. На прозрачном фоне порог не значит ничего: там всё уже сказано прозрачностью.

Штука в том, что порогом можно не только спасти бледную линию, но и получить другой рисунок из того же файла. Взял картинку с двумя штрихами — чёрным и светло‑серым — и прочитал её дважды:

порог 0.5:  34  41  47  51  53  52  48  41  33
порог 0.8:  45  50  47  52  55  64  58  54  40

При пороге по умолчанию серый штрих — это фон, читается один чёрный, и значения ложатся точно на него ровной дугой. Поднял порог — серый стал краской, в колонке появилась вторая кромка, и по правилу двух замеров получился коридор: значения задрожали. Ничего в файле не менялось, поменялось только то, что движок считает нарисованным.

В SVG вертикальная ось растёт вниз — так её хранят все редакторы. Движок её переворачивает, так что «выше на экране» всегда означает «больше по значению», и думать об этом не надо.

Файл мы читаем один раз, на старте, и превращаем в геометрию. Дальше каждая строка считает свою точку сама, из своего номера. Поэтому картинка ничего не весит по ходу прогона: миллион строк занимает столько же памяти, сколько десять, и на потоковом движке всё это спокойно льётся в файл.

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

Что из этого следует

Что в итоге вышло? Готовые распределения отвечают на вопрос «какой формы бывает случайность». Рисунок отвечает на другой: «какой формы она нужна мне». Суточный профиль, разгон и торможение, датчик, который к вечеру уходит в занос, сезонность с двумя пиками разной высоты — всё это описывается формулой — если ты силён в математике, или мышкой за минуту.

Инструмент называется TDCV2, лежит под MIT.

○ Часть 1 — Связи между полями
○ Часть 2 — Свободный формат вывода
○ Часть 3 — Справочники
● Часть 4 — Форма данных рисунком

Буду очень рад любым коментариям! Найдете ошибку, не стесняйтесь сообщить о находке и поправлять меня в комментариях. Увидимся в следующей статье. Всем добра!