Цикл «Данные без противоречий» — про 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
Ночной провал в 350 запросов, утренний подъём, дневная просадка и вечерний пик в 2933
Ночной провал в 350 запросов, утренний подъём, дневная просадка и вечерний пик в 2933

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

Идея

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

Горизонталь - номер строки, вертикаль - значение. Одна и та же картинка растягивается на любое число строк
Горизонталь — номер строки, вертикаль — значение. Одна и та же картинка растягивается на любое число строк

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

<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"/>
Шкала у обеих картинок одна. Слева рисунок растянут на весь y_range, справа - прижат к объявленной полосе
Шкала у обеих картинок одна. Слева рисунок растянут на весь y_range, справа — прижат к объявленной полосе
без 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 она полностью скрыта под линией: он идёт по ней точка в точку
Серым пунктиром — нарисованная ломаная. У linear она полностью скрыта под линией: он идёт по ней точка в точку

Выбирать тут просто. 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"/>
Первые десять замеров укладываются в размах 16 единиц, последние десять - в 205
Первые десять замеров укладываются в размах 16 единиц, последние десять — в 205
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 пикселей шириной, а строк мы попросим четыре миллиона. Интересно, что происходит в обратную сторону — когда строк меньше, чем деталей.

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

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

На 101 строке пик пойман точно. На десяти строках замеры стоят через 10% ширины, а пик занимает 2% - между ними он и проваливается
На 101 строке пик пойман точно. На десяти строках замеры стоят через 10% ширины, а пик занимает 2% — между ними он и проваливается

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

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 — Форма данных рисунком

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