Цикл «Данные без противоречий» — про 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"/>
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 годится, пока читатель ваших данных не смотрит на скорость изменения: ровный шаг никого не смутит в отчёте, но выдаст синтетику на графике. 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 — Форма данных рисунком
Буду очень рад любым коментариям! Найдете ошибку, не стесняйтесь сообщить о находке и поправлять меня в комментариях. Увидимся в следующей статье. Всем добра!

