Карта шумового загрязнения от автотранспорта для произвольной точки на карте. Пользователь кликает по адресу — сервис считает уровни шума вокруг него по европейскому стандарту CNOSSOS-EU и рисует изофоны поверх своей карты, собранной из тех же данных OpenStreetMap.
Расчёт идёт по реальной физике распространения звука: учитываются экранирование зданиями, дифракция, отражения от фасадов, поглощение грунтом и атмосферой. Это не тепловая карта «расстояние до ближайшей дороги» — двор за домом действительно выходит тише улицы, и это проверено (см. Проверка правдоподобности).
Живое демо: noisemap.online — прогретый Краснодар
в режиме CACHE_ONLY: карта, изофоны и переключение периодов суток на месте,
а расчёт по клику для новых точек выключен, чтобы демка не занимала все ядра
сервера на четверть часа (см. Боевой сервер).
Lden вокруг Тверской улицы. Тёмная полоса — 70–80 дБ(A) вдоль проезжей части, оранжевый растекается вглубь кварталов, за домами уровень падает. Круглая граница — зона показа радиусом 750 м; масштаб карта подобрала под неё сама.
- Статус
- Как это устроено
- Откуда берутся данные о трафике
- Почему не Яндекс Карты
- Требования
- Запуск
- Рельеф
- HTTP-слой
- Что проверяется автоматически
- Фронтенд
- Слой шума на карте
- Производительность
- Проверка правдоподобности
- Грабли
- Структура
- Дальше
- Деплой
- Железнодорожный шум — незавершённая ветка
- Лицензии и данные
- Дисклеймер
Расчётное ядро готово и проверено. Сквозной пайплайн от координаты до GeoJSON изофон работает, замеры и верификация приведены ниже.
HTTP-слой готов: очередь с дедупликацией, кэш по сетке, SSE-прогресс, отмена расчёта и ограничение частоты запросов по IP.
Фронтенд работает и проверен в браузере. Клик по карте запускает расчёт, изофоны ложатся поверх подложки, переключение периодов суток работает. Пока идёт холодный расчёт, показывается предварительная карта всей площади — она готова примерно через минуту, точная заменяет её позже.
Карта своя целиком, ключей в проекте не осталось. Подложка — векторные тайлы из OSM, собранные здесь же (Краснодарский край, 155.7 МБ); поиск по адресу — Nominatim. Почему ушли с Яндекс Карт и чем за это заплатили, разобрано в разделе Почему не Яндекс Карты.
координата (lat, lon)
│
▼
Overpass API ──────────► extract.osm дороги, здания, землепользование
│ в радиусе (показ + дистанция распространения)
▼
Import_OSM ──────► BUILDINGS, ROADS, GROUND
│ трафик проставляется по классу дороги (WG-AEN)
▼
Delaunay_Grid ──────► RECEIVERS, TRIANGLES
│ сетка приёмников, ограниченная квадратом вокруг зоны показа
▼
Noise_level_from_traffic ──► RECEIVERS_LEVEL
│ CNOSSOS-EU: эмиссия + распространение
│ периоды D / E / N / DEN, октавы 63 Гц … 8 кГц
▼
Create_Isosurface ────► CONTOURING_NOISE_MAP
│ полигоны по диапазонам дБ(A), по одному на ячейку Делоне
▼
ST_Union по уровню ──► CONTOURING_DISSOLVED
│ склейка фрагментов + упрощение, пока координаты в метрах,
│ и обрезка по кругу радиуса показа
▼
Change_SRID → Export_Table ──► isophones.geojson (EPSG:4326)
│
▼
округление координат до 6 знаков (~0.1 м)
Расчёт выполняет NoiseModelling 6.0.0 — референсная реализация CNOSSOS-EU от Université Gustave Eiffel и CNRS. Писать модель распространения звука с нуля — это человеко-месяцы работы и гарантированные ошибки в физике, поэтому здесь используется движок, на котором строят официальные стратегические карты шума по директиве ЕС 2015/996.
Затухание вдоль каждого пути «источник → приёмник» считается один раз, а эмиссия пересчитывается под трафик каждого периода. Поэтому день, вечер, ночь и интегральный Lden получаются за один прогон, без четырёхкратной цены.
Это самое слабое место любой такой карты, поэтому — честно.
Яндекс.Пробки не подходят как источник, по двум независимым причинам: публичного API с числовыми данными по сегментам нет (доступны только тайлы-картинки и общегородской балл 0–10), а сама «загруженность» — это про скорость, а не про поток. Связь между ними немонотонная: в глухом заторе машины ползут, поток близок к нулю и шума там меньше, чем на свободной магистрали. Карта, построенная по баллу пробок, местами была бы обратна реальности.
Поэтому интенсивность движения оценивается по классу дороги в OSM
(highway, lanes, maxspeed) согласно
Good Practice Guide for Strategic Noise Mapping and the Production of Associated
Data on Noise Exposure, Version 2 (WG-AEN). Эта таблица соответствий встроена в
Import_OSM — то есть используется официальная европейская методичка, а не
самодельные коэффициенты.
Что это значит на практике. Результат — обоснованная оценка, а не измерение. Геометрия, экранирование и физика распространения считаются точно; входной поток машин — типовой для данного класса улицы. Для сравнения районов между собой этого достаточно, для экспертизы или суда — нет.
Куда двигаться за точностью, если понадобится: платные исторические данные
(TomTom Traffic Stats, HERE Traffic Analytics) или открытые данные счётчиков
муниципалитета — оба варианта ложатся в таблицу ROADS вместо дефолтов.
Сервис начинался на Яндекс Картах: подложка через JS API 3.0, поиск по адресу через HTTP Геокодер, два разных ключа. Сейчас ни того, ни другого нет — подложка своя, поиск по данным OSM. Решение принято 2026-09-05, и вот из чего оно сложилось.
Бесплатный суточный лимит — порядка сотни обращений. Для разработки этого достаточно, для открытого сервиса — нет, и оптимизировать тут нечего: JS API грузится при каждом открытии страницы, так что сотня запросов в сутки это ровно сотня посетителей в сутки. Потолок аудитории равен лимиту.
С геокодером было чуть иначе, но не лучше. Запрос уходит только по нажатию, от
трёх символов, без подсказок на лету — то есть расход и так был минимальным.
Зато защиты от одного посетителя не было: при GEOCODE_LIMIT_RPM=20 один
адрес, не нарушая лимита, выбирал суточную квоту за пять минут, после чего поиск
лежал до полуночи для всех.
Бесплатное использование JS API разрешено только в открытых некоммерческих проектах. Отдельно условия запрещают сохранять полученные через API данные — то есть кэш геокодера, первое, что приходит в голову при упоре в лимит, был бы нарушением. У ODbL такого ограничения нет: хранить можно, требуется атрибуция, и она есть — в панели интерфейса и в стиле карты.
Есть и следствие, которое легко пропустить: у Яндекса, как и у Google, данные, полученные через API, полагается показывать на их же карте. Поэтому сменить подложку и оставить их геокодер значило бы поменять одно нарушение условий на другое — оба перехода делались одной работой, а не по очереди.
Это редкая и удобная позиция: Яндекс не поставлял ни одного числа, которое участвует в расчёте. Пробки не использовались (причины — в разделе выше про трафик), изофоны считались из OSM. Подложка и геокодер были всем, и оба заменялись, не трогая модель.
Экранирование считается по зданиям OSM, а нарисованы были яндексовы. Это разные наборы, и расхождение читалось как ошибка расчёта: тихая зона за домом, которого на подложке нет, выглядит как враньё модели. Теперь карта и модель говорят об одних и тех же домах.
Заодно исчез целый класс отказов, которые приходилось объяснять: ограничение по
HTTP Referer, которое должно было оставаться настроенным, иначе карта на сайте
отдавала 403 Invalid api key; необходимость добавлять боевой домен в кабинете
после деплоя; ключ в бандле и в истории git.
Честно, потому что цена есть.
- Ранжирование адресов стало хуже. Nominatim не слышит город в свободном русском запросе — подробности и замер в разделе Поиск по адресу. Яндекс здесь был лучше.
- Подложка беднее в частном секторе и новостройках: у OSM по России данные местами реже. Для этого продукта терпимо — ценность в изофонах, а не в POI.
- Появился шаг установки. Тайлы надо собрать (
scripts/build-tiles.mjs) и подключить каталогом; раньше карта приезжала из чужого CDN сама. - География теперь конечна. Подложка покрывает Краснодарский край, и за его пределами карта пустая — раньше мир был весь.
Старый ключ карт лежит в истории git (d1db820, удалён в 4da2ffd). Раньше на
этом висело живое условие — ограничение по referer должно было оставаться
настроенным, иначе ключ стоило украсть. Теперь условия нет: ключ ничем не
используется, и его можно спокойно отозвать в кабинете. Ключа геокодера в истории
нет — он был серверным и в бандл не попадал.
| Node.js | ≥ 20 |
| Java | проверено на OpenJDK 25 — работает, хотя NoiseModelling собран под более старую версию. Нужна и для сборки подложки: Planetiler тоже на JVM |
| NoiseModelling 6.0.0 | распаковать в .tools/nm/ (148 МБ, в git не хранится) |
npm install
node scripts/build-tiles.mjs # подложка: тайлы, глифы и стильКлючей нет ни одного. Подложка своя, поиск по адресу — по данным OSM. .env
нужен только там, где машина сидит за прокси или где геокодер свой; в git он не
попадает, в репозитории лежит только шаблон.
Сборка подложки — единственный долгий шаг установки: несколько гигабайт временных файлов и десятки минут работы Planetiler. Подробности и как обойтись малой кровью — в разделе Подложка.
NoiseModelling скачивается со страницы релизов
(архив NoiseModelling_6.0.0.zip) и распаковывается так, чтобы получился путь
.tools/nm/NoiseModelling_6.0.0/bin/ScriptRunner.bat.
После npm install, распаковки NoiseModelling и заполнения .env — два
терминала:
npm run server # API на :8787, он же запускает расчётыnpm run web # Vite на :5174, проксирует /api на :8787Открыть http://localhost:5174 и кликнуть по карте.
Обычно нет. Прокси нужен только там, откуда не открывается Overpass — а именно он поставляет исходные данные, без которых расчёт не начнётся. Проверить это можно до всякой установки:
curl --max-time 20 https://overpass-api.de/api/statusОтвет вида Connected as: … — прокси не нужен, дальше можно не читать. Таймаут
или ошибка соединения — нужен. Наружу ходят зеркала Overpass (данные OSM, список
ниже), s3.amazonaws.com (тайлы рельефа) и nominatim.openstreetmap.org (поиск
по адресу). Сама подложка наружу не ходит вовсе — она лежит на этом же сервере.
Сборщик подложки тоже ходит наружу, и вдобавок запускает Java, которая
переменные окружения про прокси не читает: build-tiles.mjs переводит их в
системные свойства JVM сам.
Адрес прокси прописывается в .env, рядом с ключами:
HTTPS_PROXY=http://127.0.0.1:10809Этот файл читают и сервер, и скрипты, так что переменную достаточно записать
один раз. Подойдёт любой HTTP-прокси (схема http:// или https://) — в
том числе локальный клиент вроде v2ray или Shadowsocks, у которых HTTP-порт
обычно соседствует с SOCKS-портом: нужен именно HTTP. Схема socks5:// не
поддерживается — библиотека, через которую ходят запросы, её не понимает.
Переменная из оболочки перекрывает .env, поэтому разовый запуск можно сделать
и так:
HTTPS_PROXY=http://127.0.0.1:10809 npm run server # bash
$env:HTTPS_PROXY='http://127.0.0.1:10809'; npm run server # PowerShellПроверить, что настройка доехала, проще всего по первой строке лога сервера: он
печатает либо исходящие запросы через прокси …, либо предупреждение, что
прокси не настроен. Без прокси там, где он нужен, падает каждый расчёт, в
какую точку ни кликни — с сообщением про недоступный Overpass.
Overpass поставляет всё, из чего строится расчёт: дороги, здания,
землепользование. Он же — единственная внешняя зависимость, без которой не
начинается ничего, и работает он на публичных инстансах, общих со всем миром.
Под нагрузкой они отвечают 504 или 429 вместо данных, и это не редкость.
Своего источника данных у проекта нет — это остаётся настоящей единственной точкой отказа. Что сделано, это перестало быть ставкой на один сервер:
- Четыре зеркала вместо одного. По умолчанию
overpass-api.de,overpass.kumi.systems,maps.mail.ruиoverpass.private.coffee. Из них с машины разработки проверены ответом на живой запрос два первых по списку иmaps.mail.ru;kumi.systemsиprivate.coffeeотсюда недоступны через местный прокси — это известные публичные инстансы, но проверить их с этой машины не удалось, и брать их работоспособность на веру не нужно. - Ведущее зеркало меняется от раунда к раунду. Иначе первое в списке принимает первую попытку каждого клиента, а остальные видят трафик только когда оно уже перегружено.
Retry-Afterслушается. Инстанс, попросивший подождать, не трогается до названного им срока — в том числе во втором запросе той же задачи (задача спрашивает Overpass дважды: дороги, потом пути).- Пауза между раундами с джиттером. Упавшие вместе сервера поднимаются вместе, и ровная лестница 5 с, 10 с, 20 с отправляет к ним всех ждущих одновременно — так восстанавливающийся инстанс роняют повторно.
- Сломанный запрос не объезжает зеркала.
400от Overpass — это про наш запрос, и на всех зеркалах он будет тем же; цикл прекращается сразу. - Есть общий потолок времени. Четыре зеркала на три раунда по 240 с — это
почти час на клик, весь расчёт которого занимает пятнадцать минут.
OVERPASS_BUDGET_MS(по умолчанию 5 минут) ограничивает всё вместе, и отдельная попытка получает не весь остаток, а свою долю: молчащее зеркало иначе съедает время, которое досталось бы работающим.
Список заменяется целиком через OVERPASS_ENDPOINTS в .env — и это
единственное настоящее лекарство: поднятый свой инстанс убирает зависимость от
чужого сервера, а всё перечисленное выше только смягчает её.
OVERPASS_ENDPOINTS=http://localhost:12345/api/interpreterНе всякое зеркало годится. overpass.osm.ch отвечает быстро и корректно, но
данные у него только по Швейцарии: на запрос по Москве он возвращает пустой
ответ, а не ошибку. Проверено — поэтому в списке его нет. Прежде чем добавлять
зеркало, спросите у него точку заведомо за пределами вероятного региона.
Что стоит знать до первого клика:
- кэш в свежем клоне пуст (
cache/не хранится в git), поэтому первый расчёт для любой точки идёт честные 6–27 минут — карта при этом строится на глазах, а ссылка «Отменить» останавливает расчёт по-настоящему; - демо-точки можно прогреть заранее, пока сервер запущен:
node scripts/prewarm.mjs moscow— это те же минуты, но без ожидания у карты; - без собранной подложки карта не отрисуется, но расчёт и API работают: сервер
можно проверить без браузера —
node scripts/smoke-api.mjs. Интерфейс в этом случае прямо говорит, что тайлы не собраны, а не показывает пустое поле; - поиск по адресу ходит к публичному Nominatim и ничего не требует настроить.
Если что-то не запускается, проверьте связку с движком отдельно от веба:
node scripts/run-job.mjs --lat 55.7649 --lon 37.6055 --radius 300 --dem 0Это самый дешёвый прогон целиком: Overpass, Java, NoiseModelling и выгрузка
GeoJSON — около трёх минут (замерено: 164 с, из них 160 с сам движок). Падает он
ровно там, где не хватает Java или дистрибутива в .tools/, и не требует ни
ключей, ни браузера.
node scripts/run-job.mjs --lat 55.7558 --lon 37.6173 --radius 750 --maxSrcDist 350 --maxArea 5000 --reflOrder 0Результат — jobs/<id>/isophones.geojson.
| Флаг | Смысл | По умолчанию |
|---|---|---|
--lat, --lon |
центр расчёта, обязательные | — |
--radius |
радиус показа: по нему обрезается результат, приёмники строятся в описанном квадрате | 1000 |
--srcRadius |
радиус выгрузки OSM: до какой дали берутся источники | radius + maxSrcDist |
--maxSrcDist |
максимальная дистанция источник–приёмник | 500 |
--maxArea |
макс. площадь треугольника Делоне, м² — разрешение сетки | 2500 |
--dem |
учитывать рельеф (1/0) |
1 |
--diffVertical |
дифракция через крыши (1/0) |
1 |
--diffHorizontal |
дифракция вокруг углов зданий (1/0) |
0 |
--reflOrder |
порядок отражений от фасадов | 1 |
--simplifyTolerance |
упрощение геометрии изофон, м | 1.0 |
--coordDecimals |
знаков после запятой в координатах выхлопа | 6 |
node scripts/sanity-check.mjs jobs/<id>/isophones.geojson DENnode scripts/inspect-geojson.mjs jobs/<id>/isophones.geojsonВысоты берутся из terrarium-тайлов (AWS Open Data): глобальное покрытие,
без ключа и регистрации. Высота закодирована прямо в пикселе:
(R × 256 + G + B / 256) − 32768 метров. Тайлы декодируются, значения
раскладываются на регулярную сетку и записываются в ESRI ASCII-грид, который
Import_Asc_File превращает в облако 3D-точек. Дальше — тот же приём, что с
изофонами: импорт в WGS84 и Change_SRID в рабочую метрическую проекцию.
Сетка регулярна в градусах, поэтому её ячейки не квадратные на местности. Это безобидно: NoiseModelling всё равно превращает растр в отдельные точки и перепроецирует каждую.
Мысль «в городе всё плоское» не выдерживает проверки. Замер по площадке 1.7 км:
| Место | Перепад высот |
|---|---|
| Москва, центр | 52 м |
| Москва, Тверская | 28 м |
| Москва, Хамовники | 22 м |
Пятьдесят метров на полтора километра — это уклон около 3%, и склон экранирует звук примерно как здание.
Декодирование проверено сверкой с независимым источником: по центру Москвы terrarium даёт 119–171 м, а SRTM30m через opentopodata — 120–170 м. Совпадение до метра означает, что формула распаковки высоты из пикселя применена правильно, а не даёт правдоподобный мусор.
Один и тот же участок в центре Москвы, период DEN:
| Без рельефа | С рельефом | |
|---|---|---|
| Средний уровень по площади | 64.30 дБ(A) | 63.10 дБ(A) |
| Расчёт распространения | 67 с | 108 с |
| Сменило диапазон | — | 9.6% площади |
Площадь тихих диапазонов (50–60 дБ) выросла, громких (60–70 дБ) — уменьшилась. Направление правильное: рельеф добавляет экранирование, а не убирает его. Если бы уровни поднялись, это был бы повод искать ошибку, а не радоваться новой фиче.
Сравнение считается скриптом, а не на глаз:
node scripts/compare-runs.mjs jobs/<id>/isophones_nodem.geojson jobs/<id>/isophones_dem.geojson DENПлата — примерно +60% ко времени расчёта. Включено по умолчанию: без рельефа модель молча считает город плоской плитой.
Напрашивающаяся оптимизация — брать высоты пореже. Проверено и не работает:
| Сетка рельефа | Точек | Расчёт | Отличие от подробной |
|---|---|---|---|
| шаг ~28 м | 6758 | 108 с | — |
| шаг ~67 м | 1196 | 106 с | 2.2% площади |
Время не изменилось. Цена рельефа не в количестве точек, а в том, что его включение переводит NoiseModelling на более дорогой путь расчёта профилей вдоль лучей. Поэтому используется подробная сетка — она обходится бесплатно.
npm run server # сборка TypeScript + запуск на :8787
node scripts/smoke-api.mjs # сквозная проверка API
npm test # юнит-тесты чистых функций (без Java и сети)Сервер не дублирует логику расчёта — он запускает тот же run-job.mjs
подпроцессом и разбирает его вывод. CLI остаётся единственным источником правды,
а всё, что добавляет сервер, — это очередь, кэш и трансляция прогресса.
| Метод | Путь | Что делает |
|---|---|---|
GET |
/api/noise?lat=&lon= |
посчитано ли это место: {id, centre, radius, cached, state?}. Ничего не запускает |
GET |
/api/noise/areas?bbox= |
посчитанные области в прямоугольнике: {areas: [{id, lat, lon, radius}]} |
POST |
/api/noise |
{lat, lon} → {id, cached, centre}. 200 если готово, 202 если поставлено в расчёт; covering: true означает, что отдан накрывающий расчёт соседа. {preview: false} отключает предварительную карту для этой задачи |
GET |
/api/noise/:id/events |
SSE-поток прогресса, закрывается сам по завершении |
GET |
/api/noise/:id/result |
GeoJSON изофон, gzip если клиент его принимает |
DELETE |
/api/noise/:id |
«больше не жду» → {cancelled, waiters, state}. cancelled: false, если задачу ждут другие |
GET |
/api/noise/:id/preview |
предварительная карта всей площади, пока расчёт идёт |
GET |
/api/noise/:id/partial/:n |
карта на момент n-го кадра, пока расчёт идёт |
GET |
/api/config |
{radius} — что интерфейсу нужно знать до первого расчёта |
GET |
/api/geocode?q= |
поиск по адресу → {places: [{name, description, lat, lon, precision}]} |
GET |
/api/health |
проверка живости |
Одновременных расчётов по умолчанию один: распространение и так забирает все ядра, и второй параллельный запуск делает медленнее оба. Запросы к уже считающейся точке не создают вторую задачу, а подключаются к существующей — два человека, кликнувшие в один квартал, разделяют один расчёт.
DELETE /api/noise/:id означает «я больше не жду этот результат», а не «убей
задачу». Разница существенна ровно из-за дедупликации: расчёт общий, и один
клиент не должен уметь оборвать его тому, кто всё ещё смотрит на прогресс.
Сервер считает ожидающих — каждый POST, подключившийся к задаче, добавляет
одного — и останавливает работу, когда ушёл последний. Ответ говорит, что
получилось:
{ "cancelled": false, "waiters": 1, "state": { "stage": "propagation", ... } }Останавливается по-настоящему, а не только на бумаге. Цепочка процессов —
сервер → run-job.mjs → ScriptRunner → JVM, и все ядра с 1.8 ГБ занимает
последнее звено. Убийство среднего оставило бы дорогое работать сиротой, поэтому
под Linux подпроцесс запускается лидером своей группы (detached) и сигнал
уходит всей группе, а под Windows дерево обходит taskkill /T. Проверено на
живой задаче: после отмены на стадии распространения java.exe пропадает из
списка процессов за несколько секунд.
Плата за detached — расчёт перестал умирать вместе с терминалом, поэтому
сервер снимает задачи сам по SIGINT и SIGTERM (последний шлёт docker stop).
Закрытая вкладка ничем не отменяет: задача досчитается и попадёт в кэш, как раньше. Это осознанно — браузер не обещает отправить запрос при закрытии, а отмена по обрыву SSE ломала бы обычную перезагрузку страницы.
Отменённая задача живёт как надгробие: GET /result отвечает 409 расчёт отменён вместо «ещё не готово», а следующий POST в ту же точку запускает
расчёт заново, а не присоединяется к несуществующему.
Каталог задачи назван по точке — jobs/<широта>_<долгота>_r<радиус>s<радиус источников>, — и файлы внутри имеют фиксированные имена: база H2,
isophones.geojson, кадры. Два прогона одной точки поэтому пишут в одну базу и
перемешивают её таблицы. Это не гипотеза: 2026-08-19 сервер сняли taskkill /F,
расчёт его пережил, следующий клик в ту же точку открыл ту же базу — и одна
точка дала два разных результата.
Поэтому run-job.mjs перед первой же записью занимает jobs/<id>/run.lock
(создание файла с флагом wx — это и есть атомарная часть), а второй прогон той
же точки не стартует и говорит почему: «эта точка уже считается — подождите и
попробуйте снова».
Лок — не просто файл, а признак жизни: владелец касается его каждые 15 секунд.
Брошенным лок считается, когда он молчит больше минуты или когда записавшего
его процесса уже нет. Нужны обе половины: pid переиспользуются системой, а
убитый прогон оставляет после себя ещё свежий файл — иначе отменённую задачу
нельзя было бы перезапустить раньше чем через минуту. Свой лок прогон снимает на
выходе, в том числе по SIGINT и SIGTERM, но под Windows taskkill /F не даёт
отработать ничему, так что надёжность держится на перехвате брошенного, а не на
уборке за собой.
Ждать освобождения вместо отказа было бы вежливее, но честного ответа «сколько ждать» нет: занявший каталог прогон считает ту же точку и может быть в секунде от конца, а может в получасе. Уникальный подкаталог на прогон закрыл бы ту же дыру, но каталог заодно работает кэшем: выгрузка OSM и растр рельефа переиспользуются следующими прогонами той же точки — это сэкономленные минуты и снятая нагрузка с Overpass.
База удаляется дважды, и это два разных требования. Перед прогоном — ради
правильности: Import_OSM пишет в таблицы с фиксированными именами и дописывает
в то, что нашёл, поэтому база, оставшаяся от убитого прогона, подмешалась бы в
следующий. После удачного прогона — ради места: замерено 11 МБ на разреженном
краснодарском тайле и 46 МБ на плотном — от трёх до десяти раз больше, чем всё
остальное содержимое каталога вместе взятое. Удаление перед прогоном забирает
только те точки, которые считают дважды, — в батче на целый город таких нет ни
одной. Прогрев на 254 тайла оставлял бы 4–13 ГБ, а оставляет ~1.4 ГБ: выгрузки
OSM и растры рельефа, которые нужны и дальше.
Второе удаление — best effort по природе: убитый прогон не выполняет ничего,
поэтому гарантией остаётся первое. Упавший прогон свою базу сохраняет — открывать
захотят именно её, — а KEEP_JOB_DB=1 сохраняет её у любого: inspect_db.groovy
читает таблицы оттуда, и другого способа увидеть, что построил блок, нет.
Расчёт стоит минут процессорного времени, и без счётчика публичный сервис занимается несколькими кликами. Бюджеты — токенные корзины на IP, отдельные для разных ресурсов:
| Корзина | По умолчанию | Что защищает |
|---|---|---|
job |
2 сразу, 6 в час (JOB_LIMIT_BURST, JOB_LIMIT_PER_HOUR) |
ядра и память: холодный расчёт идёт минуты, и одновременно он один |
geocode |
20/мин (GEOCODE_LIMIT_RPM) |
очередь к геокодеру — она одна на весь сервис |
api |
60/мин (RATE_LIMIT_RPM) |
всё остальное от потока запросов |
Ключевое: бюджет расчётов тратится только на запуск новой работы. Попадание
в кэш и подключение к уже идущей задаче не стоят ничего, поэтому ходить по
прогретым местам можно сколько угодно — ограничение против занятия очереди, а не
против чтения. Отказ приходит как 429 с Retry-After и внятным текстом;
успешные ответы несут RateLimit-Limit и RateLimit-Remaining, чтобы клиент мог
притормозить заранее. /api/health не считается вовсе: платформенная проба
живости не должна упираться в лимит именно тогда, когда сервис под нагрузкой.
Отдельно ограничено число одновременных подписок на прогресс с одного
адреса (STREAM_LIMIT_PER_IP, по умолчанию 6). Корзины считают частоту, а SSE
— это занятость: соединение живёт всё время расчёта и держит сокет, слушателя и
таймер, а стоило одного токена. Без отдельного счётчика сотня висящих потоков
укладывалась в любой бюджет. Лишний поток получает 429 вместо
text/event-stream, и браузерный EventSource на такой ответ закрывается, а не
переподключается по кругу. Место освобождается, как только соединение
закрывается — в том числе когда клиент просто исчез.
Корзина — а не окно — потому что трафик здесь пачками: открытие страницы делает несколько запросов подряд, и фиксированное окно либо отклонило бы это, либо пропустило двойную норму на стыке.
Запросы с самой машины (loopback) не ограничиваются: prewarm.mjs запускает
задачи подряд через тот же API, и оператор, греющий собственный кэш, — не та
нагрузка, от которой это защищает. Снимается переменной RATE_LIMIT_LOOPBACK=1,
она же нужна, чтобы проверить лимиты локально:
RATE_LIMIT_LOOPBACK=1 npm run server
node scripts/smoke-api.mjsЗа обратным прокси адрес клиента виден только в X-Forwarded-For, и заголовок
этот подделывается тривиально — доверие к нему включается явным
TRUST_PROXY=1. Без него на сервере за прокси все запросы придут с одного
адреса и лимит станет общим на всех; с ним на сервере, доступном напрямую, любой
желающий отключит себе лимит одной строкой в заголовках.
POST /api/noise отвечает на вопрос «посчитано ли здесь?» тем, что начинает
считать, — то есть узнать это стоило минут на всех ядрах и занимало очередь.
GET /api/noise?lat=&lon= отвечает тем же id, центром ячейки и радиусом, но
без побочных эффектов: он сообщает, есть ли результат в кэше и идёт ли прямо
сейчас расчёт для этой ячейки (state), и ничего не создаёт.
Полезен он для точечных вопросов; чтобы показать, где уже посчитано, есть отдельный запрос — следующий раздел.
Раньше готовность места показывалась под курсором: круг заливался, когда навёл ровно туда, где есть расчёт. Так узнать о посчитанной области можно было, только случайно попав в неё мышью, — то есть практически никогда.
Теперь GET /api/noise/areas?bbox=minLon,minLat,maxLon,maxLat отдаёт все
посчитанные места, попадающие в прямоугольник, а карта затеняет их. Запрос уходит,
когда камера остановилась, и берёт рамку в полтора экрана: возить карту внутри
уже загруженной области ничего не стоит. Ниже 11-го зума не спрашивается вовсе —
там круг радиуса 750 м занимает пару пикселей, а рамка накрыла бы полстраны.
Через провод идут только центры и радиусы: область — это диск, и три числа дешевле его контура. Радиус хранится у каждой записи отдельно, потому что запись могла быть посчитана при другом радиусе.
Диски перед отрисовкой объединяются в одну фигуру. Отдать их карте как мультиполигон недостаточно: рендерер заливает каждый полигон отдельно, поэтому на пересечениях прозрачность складывалась в тёмное пятно, а у каждого диска оставался свой контур — скопление посчитанных мест читалось как куча кружков, а не как одна область. Сделать то же самое стилем нельзя: в стиле фигуры нет ни режима наложения, ни прозрачности слоя, а единственный доступный обход — непрозрачная заливка — закрыл бы улицы, поверх которых затенение и рисуется.
Объединение считает polygon-clipping — плюс 8.6 КБ в gzip к бандлу (63.4 → 72.0)
за операцию, где самописная геометрия ошибается тихо и криво. Дырки между дисками
переживают объединение как внутренние кольца, отсюда fillRule: 'evenodd'.
Две вещи, которые пришлось сделать на сервере:
- Индекс кэша. Ключ кэша — хеш от округлённых координат и параметров расчёта, и обратно из него место не достать. Поэтому рядом с результатом теперь пишется файл с центром и радиусом, а записи, сделанные до этого, разбираются один раз: результат обрезан по диску, значит рамка его геометрии и есть этот диск. Восстановленное записывается тем же файлом — второй раз читать двухмегабайтные карты не придётся.
- Отсев чужих записей. В кэше лежат и результаты прежних параметров расчёта —
их ключ сегодня никто не запросит. Затенять их нельзя: клик по такой области
запустил бы расчёт на четверть часа вместо мгновенного ответа. Проверка точная:
запись показывается, только если
cacheKeyеё центра совпадает с именем файла.
Холодный клик стоит минуты, и всё это время показывать пустую подложку незачем. Сейчас ждущему показывают предварительную карту всей площади — ей посвящён следующий раздел. Начинали с другого механизма, кадров живого рендера: они остались в коде, по умолчанию выключены, и именно их замеры объясняют, почему предпросмотр устроен так, как устроен.
Кадр — это карта из того, что уже посчитано. Пайплайн выгружает её прямо по ходу
расчёта, а SSE сообщает номер очередного кадра: геометрию по каналу статусов не
гоняют, клиент забирает кадр сам по /api/noise/:id/partial/:n.
Это возможно потому, что NoiseModelling пишет результаты не в конце, а по
ходу: NoiseMapWriter отдельным потоком сливает их батчами и коммитит.
Измерено на копии готовой базы — таблица уровней росла 6 148 → 41 880 → 132 548
строк, пока расчёт ещё шёл. Второе соединение к встроенной H2 из того же JVM при
этом открывается штатно, так что наблюдателю не нужно лезть во внутренности
движка.
Кадр строится из того, что уже посчитано: берутся приёмники, у которых есть строки на все периоды, из треугольников оставляются те, у которых готовы все три вершины, дальше — тот же путь, что у финального результата, вплоть до обрезки по кругу. Общая функция на оба пути не случайна: разойдись они, кадр показывал бы не то, что окажется в ответе.
Две грабли, на которые это наступило и которые стоит помнить:
- у таблицы уровней во время расчёта нет ключа — движок ставит его последним
действием. Поэтому кадр строится не по живой таблице, а по её индексированному
снимку: иначе каждый поиск приёмника превращается в полный скан и кадр не
достраивается никогда (поток стоял в
IsoSurfaceминутами); - промежуточной таблице готовых приёмников нужен первичный ключ — без него отбор треугольников уходит в перебор.
Цена кадра — секунды, и она вычитается из тех же ядер, что считают шум, поэтому
пауза между кадрами не фиксированная: она равна девятикратной длительности
предыдущего кадра, но не меньше PARTIAL_INTERVAL_MS. Так собственное время
потока кадров ограничено примерно десятой частью расчёта, что бы ни случилось с
ценой кадра на плотной застройке. PARTIAL_INTERVAL_MS=0 выключает кадры совсем.
Сколько это стоит на самом деле — замерено на чистой машине (Тверская, радиус 750 м, 12 потоков, одно и то же извлечение OSM, ничего постороннего на машине):
| Прогон | Кадры | Сколько их | Их собственное время | Расчёт целиком |
|---|---|---|---|---|
| A | включены | 7, от 2.6 до 29.7 с | 73.6 с (8.1%) | 912 с |
| B | выключены | — | — | 933 с |
| C | включены | 8, от 3.0 до 31.9 с | 102.2 с (10.6%) | 966 с |
Ограничитель обещанное держит: собственное время кадров — 8–11% расчёта. Но на стенных часах цена кадров не видна: два прогона с одинаковыми настройками (A и C) разошлись на 54 с, а прогон без кадров лёг ровно между ними. Иначе и быть не может: поток кадров — один среди двенадцати, и его доля в стенных часах равна его времени, делённому на число потоков, то есть секундам. Сумма длительностей кадров — это не цена расчёта, а верхняя граница, которую ограничитель и стережёт.
Но кадры оказались не тем, что нужно человеку у экрана, и по умолчанию они
теперь выключены (PARTIAL_INTERVAL_MS=0). Причина видна из тех же прогонов:
распространение идёт ячейками, поэтому кадр точен, но покрывает четверть круга.
На Тверской первый пришёл на 83-й секунде с 6% приёмников — россыпь пятен на
пустом круге, — а половина площади набралась только к десятой минуте. Кликнувший
в правую половину эти десять минут смотрел на пустое место.
Поэтому перед точным проходом идёт предварительный: та же сетка приёмников, те же здания, но источники берутся в пределах 75 м и без рельефа. Он даёт всю площадь сразу, и вот чего это стоит на Тверской:
| Что меняли | Распространение | Расхождение с итогом |
|---|---|---|
| боевые параметры | 926 с | — |
| без рельефа | 575 с | 6.0% площади мимо полосы |
без рельефа, maxSrcDist 150 м |
97 с | 9.9% площади, −1.1 дБ(A) |
с рельефом, maxSrcDist 150 м |
160 с | 10.2% площади, −1.4 дБ(A) |
без рельефа, maxSrcDist 75 м |
38 с | 19.0% площади, −2.7 дБ(A) |
Читается это так. Единственный настоящий рычаг цены — дальность источников:
150 м вместо 350 ускоряют распространение в девять раз, 75 м — в двадцать четыре. Ослаблять вместо
неё maxArea бесполезно — восьмикратный предел площади треугольника убрал 1.3%
приёмников (34 101 против 33 666): на городской застройке сетку Делоне задают
контуры зданий и дорог, а не предел площади. Рельеф в предпросмотре не
окупается: он стоит лишних 63 с и при этом не приближает карту к итогу — без
рельефа уровни завышаются, отброшенные дальние источники их занижают, и ошибки
частично гасят друг друга.
Сетка приёмников на оба прохода одна, и это не мелочь: геометрия изофон совпадает, поэтому точный расчёт не перерисовывает карту, а перекрашивает её на месте — без швов и без прыжка контуров.
Выбраны 75 м, а не 150 — последняя строка таблицы против третьей. Разница в цене вдвое, разница в правдивости тоже: −2.7 дБ вместо −1.1 и пятая часть площади в соседней полосе вместо десятой. Размен сделан сознательно: точный ответ всё равно в четверти часа, и целая карта через минуту после клика полезнее более верной через две с половиной. Заметнее всего это во дворах — предпросмотр рисует их тише, чем они есть.
Расплата названа в интерфейсе прямым текстом, ровно теми же числами: «уровни
занижены на 2–3 дБ, пятая часть площади попадёт в соседнюю полосу, во дворах
сейчас тише, чем будет на точной карте». Меняя PREVIEW_SRC_DIST, надо править
и эту подпись — иначе интерфейс начнёт врать о собственной погрешности. В кэш
предпросмотр не попадает никогда: там обязан лежать только законченный
результат, и отдаётся он с Cache-Control: no-store.
Второй проход стоит времени: +6% к холодному расчёту на Тверской. Платит эту цену
только тот, кто ждёт: предпросмотр включается флагом от сервера, как и кадры,
поэтому prewarm.mjs считает без него. Выключается переменной
PREVIEW_SRC_DIST=0.
Проверено на живой задаче в браузере: предварительная карта появилась на 67-й секунде после клика — из них 30 с сам проход, остальное Overpass, импорт и сетка, — и точная заменила её в конце расчёта.
Кадры живут в каталоге задачи и не попадают в кэш: в кэше обязан лежать
только законченный результат, иначе следующий кликнувший получит недосчитанную
карту. Отдаются они с Cache-Control: no-store и удаляются вместе с задачей.
Клик округляется до сетки ~100 м, и эта ячейка становится ключом кэша. Расчёт
ведётся по центру ячейки, а не по точным координатам клика: ключ и центр
обязаны совпадать, иначе второй кликнувший получит карту, центрированную по
первому. Фактический центр возвращается в поле centre, чтобы интерфейс мог
показать его честно.
Повторный клик отдаётся за ~5 мс вместо ~2 минут. Оборотная сторона сеточного округления: два клика в нескольких метрах друг от друга могут оказаться по разные стороны границы ячейки и не разделить кэш.
Поэтому попадание в ячейку — не единственный способ получить готовый ответ. Ячейка кэша это ~100 м, а результат покрывает диск в 750: площади различаются примерно в 175 раз, и клик по затенённой области почти всегда приходился мимо ячейки, с которой её посчитали. Теперь, если точной ячейки нет, сервер отдаёт готовый расчёт, чей диск накрывает клик, — из нескольких подходящих тот, в который клик попал глубже всего.
Это честно, потому что диск однороден: приёмники ограничены им, а источники и
здания берутся из выгрузки радиусом radius + maxSrcDist. Приёмнику на самой
кромке достались все источники в пределах maxSrcDist от него и все
экранирующие здания — расчёт у края такой же полный, как в середине.
Плата: карта придёт центрированной не в точке клика, а до 750 м в стороне.
Интерфейс говорит это прямым текстом и рисует центр на карте, а ответ несёт
covering: true. Заказать расчёт, центрированный именно на своей точке, внутри
уже посчитанной области больше нельзя — для демо-карты это верный размен:
альтернатива стоит четверти часа на всех ядрах ради карты, которая почти
совпадёт с имеющейся.
Ключ включает и параметры расчёта, поэтому изменение радиуса или настроек дифракции автоматически инвалидирует всё посчитанное раньше.
node scripts/prewarm.mjs # пресеты moscow + spb
node scripts/prewarm.mjs moscow # один пресет
node scripts/prewarm.mjs 55.75,37.61 # произвольная точкаХолодный клик стоит несколько минут — приемлемо, когда человек исследует карту, и фатально для ссылки, которую открывают один раз. Прогрев кладёт демо-точки в кэш заранее; всё остальное по-прежнему считается по требованию.
Скрипт следит за задачей по SSE, а не опросом /result: упавшая задача продолжает
отвечать 409 до вытеснения, так что опрос застопорил бы батч на десять минут
вместо внятной ошибки.
Предварительную карту прогрев не заказывает ({preview: false} в запросе): её
некому смотреть, а стоит она 12–22% времени расчёта.
Пара демо-точек и весь город — это одна и та же работа в разных масштабах.
plan-tiles.mjs строит план: решётку кругов по границам из OSM, отранжированную
по застройке. prewarm.mjs --plan берёт от неё столько, сколько скажет --share.
node scripts/plan-tiles.mjs krasnodar # план по границам из OSM
node scripts/prewarm.mjs --plan plans/krasnodar.json --share 0.95 # прогреть верх плана
node scripts/prewarm.mjs --grid 45.035,38.975,5 # решётка без плана, 5 кмРешётка треугольная: ряды через 1.5·R, вдоль ряда R·√3, каждый второй
ряд смещён на полшага. Каждый круг тогда накрывает правильный шестиугольник с
описанным радиусом R, шестиугольники замощают плоскость — 2.598·R² земли на
круг против π·R², который круг занимает. Квадратной решётке для того же
покрытия нужен шаг R·√2 и на 30% больше прогонов, а каждый прогон здесь — это
минуты на всех ядрах.
Ранжирование важнее решётки. Тайл получает те здания, для которых он —
ближайший центр (разбиение по шестиугольникам, без двойного счёта), и план
пишется по убыванию этого числа. Отсюда два свойства: --share режет план
префиксом, а прерванный на середине прогрев успел посчитать самую населённую
половину города, а не случайный угол. Замер по Краснодару (округ 836.9 км² плюс
Энемское и Яблоновское поселения, 211 488 зданий в OSM, круг показа 750 м):
| доля застройки | тайлов | км² | счёт | кэш raw / gzip |
|---|---|---|---|---|
| 50% | 72 | 105 | 7.7 ч | 0.11 / 0.03 ГБ |
| 75% | 140 | 205 | 12.5 ч | 0.21 / 0.05 ГБ |
| 90% | 212 | 310 | 16.3 ч | 0.33 / 0.08 ГБ |
| 95% | 254 | 371 | 18.3 ч | 0.39 / 0.09 ГБ |
| 99% | 332 | 485 | 21.8 ч | 0.51 / 0.12 ГБ |
| вся сетка | 632 | 924 | 35.1 ч | 0.97 / 0.23 ГБ |
Кривая выгодная в начале и невыгодная в конце: последние 5% застройки стоят
78 тайлов и 3.5 часа — это дальние станицы округа. А из 632 тайлов решётки 155
не владеют ни одним зданием, у 59 в квадрате выгрузки нет застройки вовсе, и у
85 нет ни одной дороги с трафиком — там прогон умрёт на import с «поблизости
нет дорог», потратив Overpass и минуту впустую. Ровнять решётку по всей
территории смысла нет, поэтому план и ранжируется.
Оценка времени — модель, а не обещание. t = 0.073·здания + 0.087·дороги,
подогнано по 14 законченным прогонам r750 из jobs/ (счётчики из
webserver.log против тайминга Export_Table): R² = 0.71, RMSE 132 с. По одним
зданиям R² = 0.01 — Москва даёт 8938 дорог на 1838 зданий, Краснодар наоборот, а
приёмники строятся по обоим. Снизу модель ограничена 120 с (быстрее не шёл ни
один замеренный прогон) плюс 40 с на Overpass и рельеф. Прогрев корректирует
остаток по фактически посчитанным тайлам и показывает ETA.
План привязан к радиусу. Радиус — половина ключа кэша, поэтому план на 750 м
наполняет кэш, который сервер на 500 м не прочитает никогда. prewarm.mjs
спрашивает /api/health (тот отдаёт JOB_PARAMS) и отказывается стартовать при
расхождении — узнавать об этом после ночи счёта незачем. Маршрут-проба для этого
не годится: для уже накрытой точки он отдаёт радиус накрывшего результата, а не
настроенный.
Батч рассчитан на прерывание: уже посчитанные тайлы отбиваются за миллисекунды, так что повторный запуск продолжает с места остановки. Ctrl+C гасит скрипт, но не конвейер — начатый расчёт сервер доводит до конца и кладёт в кэш.
Каталоги plans/ вне форматтера (biome.json, files.includes): шестьсот строк
таблицы пишутся по одной на тайл нарочно, иначе каждая распухает в восемь.
Этапы транслируются по SSE с человекочитаемыми подписями. Доля шкалы отдана пропорционально реальному времени: предварительная карта занимает 18–28%, точное распространение — 28–92%, потому что именно оно занимает почти всё время. Шкала, добегающая до 90% за пару секунд и потом замирающая на минуту, хуже, чем её отсутствие.
Внутри распространения прогресс берётся из логов NoiseModelling об обработке ячеек — это единственный настоящий сигнал, который пайплайн отдаёт наружу. Обновлений там немного (порядка восьми на весь этап), поэтому шкалу стоит сглаживать на клиенте. Ячейки считают оба прохода, поэтому шкала берёт долю той стадии, которая идёт сейчас: иначе предпросмотр прогнал бы её через весь участок распространения и начал заново.
Период суток попадает в ссылку (?period=N): поделились ночным шумом — у
получателя откроется ночной, а не сводный.
Под курсором рисуется пунктирный круг радиуса расчёта — что именно покроет клик. Во время расчёта он уступает место сплошному кругу вокруг фактического центра, то есть центра ячейки кэша, а не точки клика: считается именно эта область, и показывать надо её. Пунктир против сплошной — разница между предложением и происходящим; отдельная подпись для этого не нужна.
Радиус приходит с сервера (GET /api/config), а не задан константой во
фронтенде: это параметр расчёта, и вторая копия рано или поздно нарисовала бы
круг, которому результат не соответствует.
Масштаб при открытии ссылки и после поиска адреса подбирается под круг: радиус
приходит с сервера в ответе на POST /api/noise, свободная часть карты —
из измеренного отступа под панель. Константа не годится, потому что двигаются оба
слагаемых: радиус — параметр расчёта, а свободная область на широком экране это
колонка рядом с панелью, на узком — полоса над нижней шторкой. Клик по карте
масштаб не трогает: пользователь сам выбрал этот вид.
Рядом со счётчиком секунд стоит ссылка «Отменить». Ожидание в интерфейсе прекращается сразу, не дожидаясь ответа сервера: пользователю нечего делать с информацией о том, что расчёт продолжается ради другого клиента, — для него разницы нет, а кнопка, замирающая на время запроса, выглядела бы сломанной.
На каждый push и pull request гоняются типы, линтер, юнит-тесты, обе сборки,
идемпотентность сетки кэша — и сквозная проверка HTTP-слоя целиком
(scripts/smoke-api.mjs): создание задачи, поток прогресса, промежуточные
кадры, отмена, кэш, накрывающая область, лимит частоты, коды ответов и раздача
подложки с частичными запросами. 31 проверка, около пятнадцати секунд.
Долгое время эта проверка оставалась ручной, и не без причины: она запускает настоящий расчёт, а для него нужны Java, дистрибутив NoiseModelling на 148 МБ вне git, живой Overpass и минуты всех ядер. Ничего из этого на бесплатном раннере GitHub нет — но HTTP-слой из этого и не состоит. Поэтому в CI подменяется движок:
RUN_JOB_SCRIPT=scripts/fake-job.mjs npm run serverscripts/fake-job.mjs принимает ту же командную строку, что и run-job.mjs,
говорит на том же протоколе маркеров @@, пишет те же файлы в те же места и
выдаёт настоящий FeatureCollection из колец вокруг запрошенной точки — не
посчитав ничего. Очередь, дедупликация, SSE, отмена, кэш и лимитер при этом
работают по-настоящему, потому что заглушка стоит под ними, а не вместо них.
Заглушка изолирована в обе стороны: пишет она в jobs/stub_<точка>/, а не в
рабочий каталог точки, и в ключ кэша подмешан признак подмены — сервер с
настоящим движком не прочитает выдуманную карту, даже если направить его на тот
же CACHE_DIR. Сам факт подмены сервер печатает при старте и сообщает в
GET /api/health полем engine: "stub".
Отдельным шагом проверяется режим только из кэша
(scripts/smoke-cache-only.mjs, 12 проверок): сервер поднимается второй раз, с
CACHE_ONLY=1 и тем же CACHE_DIR, и читает кэш, который наполнил предыдущий
шаг. Заглушка нужна и здесь — не чтобы считать, а чтобы ключи кэша совпали:
признак подмены входит в ключ. Скрипт отказывается работать, если сервер не
сообщил cacheOnly в /api/health, — иначе он проверял бы обычный сервер и
радостно проходил.
Чего это не проверяет — акустику. Ни одного утверждения о NoiseModelling,
о CNOSSOS и о правдоподобности чисел здесь нет и быть не может. Это остаётся за
sanity-check.mjs и compare-runs.mjs, которым нужен глаз на цифры, а не
зелёная галочка.
В CI проверка идёт с RATE_LIMIT_LOOPBACK=1 и PARTIAL_INTERVAL_MS=800
намеренно: с настройками по умолчанию smoke-тест честно помечает проверки
лимитера и кадров как пропущенные — loopback от лимита освобождён, а кадры
выключены, — и это ровно те маршруты, которых больше не покрывает ничто.
npm run web # Vite на :5174, /api проксируется на :8787React + TypeScript + MapLibre GL на своих векторных тайлах. Карта живёт
императивно, React только подаёт ей данные: источники и слои создаются один раз
по событию load, всё дальнейшее — setData на источник.
Изофоны едут одним GeoJSON-источником, а не объектом на полосу: цвет
кладётся в свойства объекта (единственная таблица цветов остаётся в
palette.ts), а порядок объектов внутри коллекции и есть порядок отрисовки —
сортировка по полосе здесь несущая. Геометрия идёт из ответа API как есть:
позиции GeoJSON [lon, lat] — это ровно то, что ждёт карта.
Карта загружается лениво, и уже не ради сообщения об ошибке, а ради веса: главный чанк — 208 КБ, MapLibre целиком лежит в отдельных чанках рядом с ней и приезжает только вместе с картой. Если подложка недоступна, приложение показывает объяснение вместо белого экрана, а панель, легенда и переключатель периодов продолжают работать.
Выбранная точка живёт в адресе — ?lat=55.7649&lon=37.6055. Результатом можно
поделиться ссылкой, а сам проект становится воспроизводимым снаружи: скриншот
выше снят headless-браузером именно по такой ссылке, а не собран руками.
Панель на узком экране — шторка с тремя высотами, а не постоянные 55% экрана. Свёрнутая — это ручка, строка поиска и переключатель периодов: 155 px из 812 на типичном телефоне, карте остаётся 81% вместо 45%. Тап по ручке поднимает до половины, перетаскивание кладёт на ближайшую из трёх ступеней. Заголовок и объяснение показываются только у полностью раскрытой: по высоте это самое дорогое, что есть в панели, и самое ненужное после первого раза.
Пока идёт расчёт, шторка сама встаёт на половину — прогресс и кнопку отмены должно быть видно — и возвращается туда, где была, когда расчёт кончился. Если её за это время подвинули руками, она остаётся там, куда её поставили. Готовый ответ из кэша шторку не двигает вовсе: он приходит быстрее, чем его успеваешь прочитать, и она мигнула бы вверх и обратно зря.
Ступени считаются в пикселях в useBottomSheet, а не в стилях: свёрнутая
высота — это высота содержимого, которую CSS сообщить не может, а перетаскивание
всё равно идёт в пикселях. Но вопрос «шторка ли это сейчас» решается замером
ширины — тем же, что и в usePanelMargin. Точка перелома остаётся в одном
месте, в стилях; вторая копия в JavaScript рано или поздно разошлась бы с ней.
Отсюда же ещё одно правило: когда шторка встала на новую высоту, карта переставляет кадр — полоса, которую панель закрывает, только что изменилась. Привязано к концу анимации, а не к самому отступу: отступ меняется ещё и когда панель растёт от прогресса и результатов поиска, и дёргать камеру на каждый такой рост нельзя.
Ищет Nominatim — геокодер по данным OpenStreetMap. Тем же данным, по которым считается шум, и это не совпадение: адрес, которого нет в OSM, привёл бы в место, где расчёту нечем работать. Поиск и модель теперь смотрят в одно.
Строка поиска обращается к /api/geocode, а не к геокодеру напрямую — но уже не
из-за ключа, которого нет. Причин две. Очередь к геокодеру одна на весь процесс
(см. ниже), а из браузера её не построить. И адрес посетителя чужому сервису
знать незачем.
У публичного инстанса правило «не чаще запроса в секунду», и оно соблюдается сервером: запросы идут строго по одному с паузой 1.1 с. Это защита чужого сервера от нас — в отличие от корзины лимитера, которая защищает нас от посетителя. Ответы кэшируются в памяти (2000 записей, ключ нормализован по регистру, пробелам и запятым), и кэш здесь не про экономию — запросы бесплатны — а про то, чтобы повтор того же адреса не вставал в очередь. ODbL хранение разрешает, требуя лишь атрибуции; условия Яндекса, для сравнения, запрещали.
Свой инстанс снимает и очередь: NOMINATIM_URL плюс
NOMINATIM_MIN_INTERVAL_MS=0.
Запрос отправляется по нажатию, а не на каждый символ — подсказки на лету означали бы запрос к геокодеру на каждую букву. Выбор адреса сразу запускает расчёт: цель продукта — узнать шум на конкретном адресе, а не просто доехать до него камерой.
Nominatim плохо ранжирует русские адреса в свободном запросе, и это воспроизводится стабильно. На «Красная улица 40, Краснодар» первая пятёрка — Сочи, Динской район, Новороссийск, Приморский, Армавир, а самого Краснодара в ней нет.
Адреса при этом верные: «Красная, 40» в крае действительно много, и полное описание в списке их различает. Проблема в ранжировании — геокодер взвешивает результаты по значимости объекта и город из запроса фактически игнорирует.
Проверено и не помогло:
| Что пробовали | Результат |
|---|---|
| Порядок слов — три варианта («улица … , город», «город, улица …», «город, улица» без слова «улица») | ответ тот же |
Структурированный запрос city=Краснодар + street=Красная улица 40 |
ответ тот же |
| Публичный Photon (тоже по данным OSM, другой движок ранжирования) | не ответил вовсе |
viewbox по рамке покрытия |
бесполезен — всё найденное и так внутри края |
По свободному русскому запросу Яндекс ранжировал лучше. Это часть цены ухода, и её стоит либо принять, либо закрывать одним из двух способов: свой инстанс Nominatim с настроенными весами, либо разбор запроса на город и улицу до обращения. Браться стоит с замером на списке реальных запросов, а не на одном — иначе легко починить пример и сломать остальное.
Карта под изофонами своя: векторные тайлы, собранные из OSM, и стиль в
basemap/style.json. Ключа нет, лимита нет, наружу за тайлами никто не ходит.
node scripts/build-tiles.mjs # всё, чего не хватает
node scripts/build-tiles.mjs --skip-tiles # только глифы и стиль, быстро
node scripts/build-tiles.mjs --force # пересобрать тайлы зановоСкладывается из трёх кусков, каждый пропускается, если уже на месте:
| Кусок | Откуда | Сколько |
|---|---|---|
planetiler.jar |
релизы onthegomap/planetiler | 89 МБ, в .tools/ |
basemap.pmtiles |
Planetiler по экстракту Geofabrik | десятки минут, гигабайты временных файлов |
| глифы Noto Sans | релиз openmaptiles/fonts | 59 МБ архива, 512 файлов на выходе |
Собранное складывается в TILES_DIR (по умолчанию tiles/) и в git не хранится:
это данные, а не исходник. Исходник — стиль, он в репозитории.
Planetiler требует Java 21 или новее, и это не то же самое, что нужно
движку. В образе стоит JRE 17 — её хватает NoiseModelling, чей байткод собран
под Java 11, — и Planetiler в этом образе падает с UnsupportedClassVersionError
(class file 65 против 61). Поэтому собрать тайлы контейнером сервиса нельзя:
либо собирать там, где есть JDK 21+, либо брать официальный образ Planetiler,
либо перенести готовый .pmtiles файлом — он самодостаточен, и никакая машина
для этого не нужна. Так и сделано на боевом сервере: тайлы собраны дома и
залиты, а глифы со стилем собраны на месте, потому что им Java не нужна вовсе.
География — Краснодарский край. Отдельного экстракта на край у Geofabrik нет,
поэтому берётся Южный ФО и обрезается рамкой (--bounds), так что лишнее в тайлы
просто не попадает. Отсюда же следствие, которое легко забыть: DEFAULT_CENTER в
camera.ts — Краснодар, и расширять покрытие надо в двух местах сразу, иначе
сервис откроется на пустом сером поле.
Тайлы режутся до z14, дальше карта дотягивает overzoom’ом. На z17, куда упирается
zoomForDisc, растягивается геометрия, а не пиксели, поэтому видимой потери нет.
Не из любви к рисованию: подложка здесь фон под изофоны, которые ложатся сверху с прозрачностью 0.55. Отсюда три правила, которые нельзя нарушать при правках.
- Всё ненасыщенное. Цветной фон под полупрозрачной палитрой смещает читаемый уровень шума — карта начинает врать цветом.
- Дороги белые с серой обводкой и читаются сквозь заливку. Карта именно про них.
- Здания рисуются с z14. Это те самые здания OSM, по которым считалось экранирование. Раньше на подложке были чужие: тихая зона за домом, которого на карте нет, читалась как ошибка расчёта.
Иконок нет вовсе, подписи только текстом — спрайт не нужен, и его нет. Подписи
берутся по {name}, а не {name:latin}: в OSM по России name уже русский, а
латиница дала бы транслит.
Номера домов подписываются с z16. Ниже их нет вовсе: OpenMapTiles кладёт
слой housenumber только на z14, выше его дотягивает overzoom, а на самом z14 в
центре Краснодара их 819 на тайл — каша поверх изофон. Цвет у них темнее
уличных подписей не из вкуса: под заливкой прозрачностью 0.55 светло-серый номер
не читается, проверено на прогретом Краснодаре.
Тем же сервером, по /tiles/, но не через serveStatic, и это принципиально
в двух местах.
Первое: Range. PMTiles — один файл на сотни мегабайт, из которого клиент
читает оглавление и отдельные тайлы по смещениям. Без ответов 206 карта не
открывается вовсе.
Второе: никакого отката на index.html. В dist-web этот откат нужен, чтобы
глубокая ссылка пережила перезагрузку страницы. Здесь он означал бы, что на месте
недостающего .pmtiles клиент получает HTML с кодом 200 — и разбирается, почему
тайлы не парсятся, вместо простого 404.
Кэширование разное по природе файлов. Глифы — immutable на год: диапазон кодов
начертания это раз и навсегда один и тот же файл. Стиль и тайлы пересобираются
под теми же именами, поэтому у них ревалидация по ETag; заметный max-age там
означал бы худший вид протухания — клиент склеил бы куски старой сборки с кусками
новой, потому что читает файл диапазонами.
Используется схема «Coloring Noise» (Beate Tomio, 2016) — одна из палитр, поставляемых с NoiseModelling. Выбрана не на вкус, а по измерениям.
Легаси-стандарт DIN 18005-2 (он же UNI 9884) имеет три инверсии светлоты, включая скачок +0.50 OKLab прямо из тёмно-зелёного в яркий жёлтый, а два его самых громких диапазона — 75–80 и выше 80 — различаются на ΔE 7.7 при нормальном зрении. То есть именно те уровни, которые важнее всего различать, он рисует почти одинаково. Для протанопии соседняя пара расходится всего на ΔE 3.4.
«Coloring Noise» монотонно темнеет на всех диапазонах от 55 дБ и выше — это диапазон, в котором принимается решение (рекомендация ВОЗ по дорожному шуму — 53 дБ Lden). Её слабейшая соседняя пара приходится на тихий конец шкалы, где цена ошибки минимальна.
Остаточная слабость — низкий контраст бледных тихих диапазонов на светлой подложке. Компенсируется обводкой каждого полигона и текстовыми подписями в легенде: уровень никогда не передаётся одним лишь цветом.
Раньше карта отдавала шум по клику: один круг радиусом 750 м, посчитанный вокруг своей точки сетки. Точка, ради которой человек кликал, могла оказаться у самого края этого круга — половина окрестности за границей, — а понять, где вообще есть расчёты, можно было только затенением, которое говорило «тут посчитано», но не показывало что.
Теперь всё посчитанное лежит на карте слоем. scripts/build-noise-tiles.mjs
превращает кэш в пирамиду векторных тайлов, а клик отвечает на вопрос «сколько
здесь децибел» мгновенно и без сети — значение считывается с уже нарисованного
тайла. Расчёт запускается только там, где показывать нечего.
# всё, что есть в кэше, все четыре периода
node --max-old-space-size=6144 scripts/build-noise-tiles.mjs
# взвесить, ничего не записывая
node scripts/build-noise-tiles.mjs --period DEN --dry-runПрогретый Краснодар: 269 расчётов → 16 445 тайлов, 121 МБ, около трёх минут.
Результат ложится в NOISE_TILES_DIR (по умолчанию tiles/noise) и отдаётся из
/api/noise/tiles/<период>/<z>/<x>/<y>.pbf.
Соседние круги в решётке перекрываются примерно на 200 м. У слоя заливки в MapLibre нет ни режима наложения, ни обрезки полигоном, поэтому нарисованные как есть они складывали бы полупрозрачность в пятно потемнее — и вдобавок в зоне перекрытия два расчёта называют разные полосы.
Резать есть по чему: шаг решётки — √3·r, а у такой решётки ячейка Вороного это
правильный шестиугольник, вписанный в круг ровно. Обрезка не теряет ничего и не
оставляет дыр. Владелец точки определяется тем же правилом, что и в
coveringArea, — иначе под курсором рисовался бы один расчёт, а клик отдавал бы
другой.
Прежде чем строить, проверено, согласуются ли два соседних расчёта там, где они перекрываются. Шесть смежных пар, 18 000 случайных точек, 12 901 сравнимая:
| доля точек | |
|---|---|
| полоса совпала | 89,9% |
| разошлись на одну полосу | 9,7% |
| на две и более | 0,4% |
На той глубине, где реально проходит разрез (0,866 радиуса), — 92,0 / 7,6 / 0,5.
Для сравнения, компромиссы, на которые проект уже пошёл: выключенный рельеф
двигает на полосу 9,6% площади, maxSrcDist 350→150 — 9,9%, а предпросмотр,
который показывают живым людям с подписью, ошибается на 19,0%. Шов тише всего
этого.
Порог отсева мелких колец привязан к пикселю зума, а не задан числом: с фиксированным порогом тайл z12 разрастается до 232 КБ, с привязкой к зуму — 85. Самые плотные тайлы, gzip: z12 — 105 КБ, z13 — 105, z14 — 66, z15 — 32, z16 — 12.
Против прежней отдачи целыми кругами экран стоит ≈2,0 МБ вместо 37 на z12 и ≈1,1 вместо 2,3 на z14 — выигрыш в два-четыре раза, а не на порядок. Главное изменение не в разах, а в единице загрузки: тайл кэшируется сам по себе, поэтому панорамирование докачивает столбец тайлов, а не круги по 750 м.
Периоды разложены по четырём отдельным тайлсетам: четыре периода в одном тайле весили бы вчетверо больше ради того, на что почти не смотрят — карту читают почти всегда как Lden.
Изофоны не покрывают плоскость сплошь: приёмники не ставятся внутри зданий, а сетка Делоне оставляет разрывы. По сетке из 1802 точек попадание пиксель-в-пиксель промахивается в 47% случаев, поэтому считывание спрашивает сначала точную точку, а потом коробку в шесть пикселей — она закрывает 86% промахов. Оставшиеся попадают на большие здания, и интерфейс говорит об этом прямо, а не выдумывает значение.
Пирамида печётся из кэша и живёт до следующей выпечки: место, посчитанное после неё, слой не покажет, пока не пересобрать. Свежий расчёт поэтому рисуется поверх слоя по-старому — отдельным результатом.
Центр Москвы (55.7558, 37.6173) — плотная застройка, худший случай. Машина: 6 ядер / 12 потоков, 32 ГБ.
| Конфигурация | Расчёт | Всего | GeoJSON |
|---|---|---|---|
квадрат 800 м, maxSrcDist 400, горизонтальная дифракция вкл. |
525 с | 549 с | 12.9 МБ |
| то же, горизонтальная дифракция выкл. | 320 с | 341 с | 13.6 МБ |
зона показа 500 м через fence, maxSrcDist 500, maxArea 2500, отражения 1 |
176 с | 182 с | 3.3 МБ |
зона показа 500 м через fence, maxSrcDist 350, maxArea 5000, отражения 0 |
70 с | 76 с | 3.6 МБ |
| то же + склейка полигонов, упрощение и округление координат | 70 с | 72 с | 0.64 МБ |
Действующая рабочая точка — показ 750 м. Замер по Тверской (55.7649, 37.6055), холодный прогон целиком, включая Overpass и рельеф:
| Шаг | Время |
|---|---|
| Overpass + рельеф (18 МБ выгрузки) | 10 с |
Import_OSM + импорт растра |
9 с |
Delaunay_Grid |
3 с |
| распространение | 789 с |
Create_Isosurface |
12 с |
| склейка + обрезка по кругу | 5 с |
| перепроецирование и экспорт | 0.3 с |
| всего | 829 с |
Та же точка на радиусе 500 м считалась 365 с. Полтора радиуса стоят 2.3× времени: площадь приёмников растёт как квадрат (2.25×), а зона выгрузки источников — в 1.7×. Пик памяти JVM вырос с 1836 до 2203 МБ.
Расчёт распространения занимает 90–96% времени; импорт, построение сетки, изофоны, перепроецирование и экспорт вместе укладываются в секунды. Оптимизировать имеет смысл только распространение.
Замеры выше сделаны на уже скачанной выгрузке OSM и потому оптимистичны. Полный цикл «клик по новому месту → результат», включая Overpass:
| Точка | Радиус 500 м, с рельефом | Радиус 750 м, с рельефом |
|---|---|---|
| Москва, Хамовники | 178 с | 354 с |
| Москва, Тверская | 365 с | 829 с |
| Москва, Садовое кольцо | 582 с | 1609 с |
Полтора радиуса стоят от двух до почти трёх раз времени — тем дороже, чем плотнее застройка: приёмников становится вдвое с четвертью больше, и каждый из них видит больше источников. Разброс внутри столбца дают та же плотность и настроение публичного Overpass. Поэтому в интерфейсе написано «6–27 минут» — обе границы измерены, а не выдуманы.
Три рычага, отсортированные по эффекту:
fenceдля приёмников — считать зону показа, а не весь bbox выгрузки. Даёт около 3×: квадрат вокруг источников (сторона 2·850 м) втрое больше квадрата вокруг зоны показа (2·500 м). Именно квадрата:Delaunay_Gridберёт от переданного полигона только его envelope, поэтому кругом эта область не была никогда — круг появляется в самом конце, обрезкой изофон.- Горизонтальная дифракция — обход звука вокруг углов зданий. В плотной застройке комбинаторно дорог, даёт ~1.6×, а на картину влияет умеренно.
maxSrcDistиmaxArea— прямой размен точности на скорость.
Create_Isosurface выдаёт по полигону на каждую ячейку Делоне, поэтому один
диапазон дБ приезжает сотнями смежных обрезков с общими рёбрами. Склейка через
ST_Union по (PERIOD, ISOLVL) схлопывает их в один мультиполигон на диапазон
и убирает внутренние швы. Упрощение выполняется до перепроецирования, пока
координаты в метрах, — иначе допуск пришлось бы задавать в долях градуса.
Отдельно: экспортёр пишет координаты с 14 знаками после запятой, то есть с нанометровой точностью. Округление до 6 знаков (~0.1 м на этой широте) само по себе срезает примерно половину объёма и заведомо ниже погрешности модели.
| Фичей | Координатных пар | Размер | |
|---|---|---|---|
после Create_Isosurface |
3005 | 310 914 | 3.6 МБ |
| после склейки и упрощения | 41 | 29 841 | 1.11 МБ |
| после округления координат | 41 | 29 841 | 0.64 МБ |
Замер сделан на радиусе 500 м; пропорции от радиуса не зависят, а абсолютный размер — да. На рабочих 750 м Тверская даёт 43 фичи, 97 238 координатных пар, 2.07 МБ и 494 КБ по проводу в gzip: втрое больше пар при 2.25× площади — разницу добирает кромка круга.
Склейка вместе с обрезкой по кругу стоит 5.4 секунды — на фоне 789 секунд расчёта это по-прежнему бесплатно.
Главный риск такой модели — молча потерять экранирование зданиями. Тогда карта
выродится в «расстояние до ближайшей дороги», внешне останется правдоподобной,
и ошибку легко не заметить. Поэтому проверка автоматизирована:
sanity-check.mjs ищет тихие зоны рядом с громкими. Резкий перепад уровня на
дистанции в десятки метров физически возможен только за счёт преграды.
Конфигурация radius 500 / maxSrcDist 350 / maxArea 5000, период DEN
(исторический замер, на котором проверка ставилась):
period DEN: 768 polygons
level range: 32.5 .. 82.5 dB(A)
loud (>=70 dB): 146 quiet (<=47.5 dB): 139
closest quiet-to-loud distance: 19 m
median quiet-to-loud distance: 106 m
VERDICT: sharp gradients present — building screening is active
Что подтверждается:
- диапазон 32.5 … 82.5 дБ(A) — соответствует ожидаемому для центра города: у крупной магистрали 75–80, в защищённом дворе ниже 45;
- тихая зона в 19 метрах от громкой — экранирование работает;
- ночь заметно тише дня: максимум 72.5 против 82.5 дБ(A), тихих полигонов 366 против 139 — суточный профиль трафика отрабатывает.
Косвенное подтверждение того же — число диапазонов после склейки: у дня их 11, у ночи 9, потому что ночью уровни 75–80 и 80+ просто не достигаются.
На рабочих 750 м та же проверка по Тверской даёт 2771 полигон, тот же диапазон 32.5 … 82.5 дБ(A), медиану 161 м и минимум 1 м. Минимум упал не потому, что экранирование испортилось: чем больше площадь, тем больше пар «двор за фасадом», и рано или поздно попадается стена, за которой уровень падает с 70+ до 47.5 на считанных метрах. Осмысленная величина здесь — медиана, и она выросла со 138 до 161 м на той же точке.
Проверка разбирает мультиполигоны на части: после склейки целый диапазон дБ представлен одним мультиполигоном с разбросанными по карте кусками, и его общий центроид оказался бы где-то между ними, что для теста на расстояние бессмысленно.
Выяснено экспериментально, из документации не следует.
-
Сигнатуры
execу блоков различаются.Import_OSM,Delaunay_Grid,Change_SRIDпринимают(Connection, input). УNoise_level_from_traffic,Create_Isosurface,Export_Tableесть ещё перегрузка сProgressVisitor. Лишний третий аргумент даётMissingMethodException. -
Нужен
Delaunay_Grid, а неRegular_Grid.Create_Isosurfaceпотребляет таблицуTRIANGLES, которую создаёт только Delaunay. -
Дифракция выключена по умолчанию. При
confDiffVertical: falseдвор получается таким же шумным, как улица. Включать обязательно — иначе теряется весь смысл использования CNOSSOS. -
confMaxSrcDistпо умолчанию 150 м. Дальние магистрали просто выпадают из расчёта, и это никак не сигнализируется. -
Приёмники ограничиваются через
fence, который принимает EWKT-строку и сам перепроецирует её в рабочую систему координат, беря envelope. -
Результат выходит в метрической проекции (UTM по долготе точки — расчёт требует метров), для веба нужен
Change_SRIDв EPSG:4326. -
Node 22 не читает
HTTPS_PROXYдляfetch. На машине за прокси Overpass выглядит полностью недоступным —UND_ERR_CONNECT_TIMEOUTвместо внятной ошибки. ЛечитсяProxyAgentизundici, см.scripts/lib.mjs.NODE_USE_ENV_PROXYпоявился только в Node 24. -
Публичный Overpass регулярно отдаёт 504. Одна попытка — не показатель доступности. Реализованы ретраи с бэкоффом по нескольким зеркалам, включая российское
maps.mail.ru. -
SSE-слушатель срабатывает синхронно при подписке. Если задача уже завершена, обработчик выполняется внутри вызова
subscribe, когда его собственный результат ещё не присвоен — обращение к такой переменной падает сReferenceErrorиз временной мёртвой зоны. Заголовки к этому моменту отправлены, поэтому общийcatchпытался записать ответ повторно и ронял весь процесс. Ничто, к чему обращается слушатель, не должно объявляться после него, аcatchобязан проверятьres.headersSent. -
Лаунчер движка называется по-разному на Windows и Linux. Код запускал
bin/ScriptRunner.bat, которого в linux-образе нет. Такие вещи не ловятся ни типами, ни тестами на машине разработчика — только попыткой представить себе среду запуска. -
Number(null)иNumber('')равны нулю, а ноль — валидная координата. Разбор?lat=&lon=без проверки на наличие параметров отправлял обычный адрес без параметров в Гвинейский залив и запускал там расчёт. -
Округление до сетки должно быть идемпотентным. Шаг по долготе зависит от широты, и если считать его от исходной широты, то повторное округление уже округлённой точки даёт чуть другой шаг — рядом с границей ячейки это перекидывает точку в соседнюю. Одно место получало бы два разных ключа кэша. Лечится вычислением шага по долготе от уже округлённой широты;
scripts/check-quantize.mjsпроверяет инвариант на ~88 тысячах точек от Сочи до Мурманска. -
У пустой таблицы
GROUNDнет SRID, и распространение её не принимает. H2GIS держит проекцию на геометриях, а не на колонке —Import_OSMобъявляетTHE_GEOMпросто какgeometry, — поэтому таблица без строк отдаёт SRID 0, иNoise_level_from_trafficпадает с «Please use a metric projection for GROUND» ещё до начала счёта. Поймано на 45.0160,39.2586: читатель OSM насчитал шесть поверхностей land cover и не вставил ни одной, так чтоBUILDINGSвышла с 1697 строками и рабочим SRID, аGROUND— с нулём строк и без него. ПараметрtableGroundAbsнеобязательный (min: 0), поэтому пайплайн передаёт таблицу только когда в ней что-то есть; движок берёт своё поглощение по умолчанию — ровно то, что и означает пустая таблица. Подсовывать фиктивный полигон нельзя: он изменил бы акустику, сказав то, чего в данных не было. -
Один нечитаемый тег OSM убивает всю выгрузку.
Import_OSMразбираетheight,building:levelsиmaxspeedкакDouble.parseDouble(v.replaceAll("[^0-9]+", ""))(строки 1111, 1114, 1333). Значение без единой цифры сжимается в"",parseDoubleбросаетNumberFormatException: empty Stringпрямо внутри SAX-обработчика osmosis, и разбор обрывается для всех объектов файла, а не только для виноватого. Так выглядят реальные данные: в Краснодаре встречаются иheight="Власова В.А."(кто-то вписал фамилию в поле высоты), иmaxspeed="RU:rural"— легальная запись подразумеваемой скорости.stripUnparsableTagsвscripts/lib.mjsснимает такие теги до движка, аrun-job.mjsприменяет её и к переиспользованной выгрузке: лежащий на диске файл — это ровно тот, из-за которого прогон упал в прошлый раз. Значения с цифрами не трогаются:"12 m"доходит до движка, единицу он снимает сам. Два других места того же разбора (1277, 1280) пустую строку проверяют — поэтому это дефект данных, а не настройки. -
MapLibre вычисляет адрес своего воркера в рантайме. Он собирает его из
import.meta.url, поэтому статически его не видит ни один сборщик: файл не попадает в сборку, карта на живом сервере просит/assets/maplibre-gl-worker.mjs, получаетindex.html(откат статики) и падает на проверке MIME-типа. Молча — в интерфейсе просто пустое поле, а единственный след в консоли говорит про MIME, а не про карту. Лечится явным импортом?worker&urlиsetWorkerUrl, см.web/src/basemap.ts. Одного?urlмало: воркер импортирует общий чанк, и его надо собрать, а не скопировать. -
tarв Git Bash — GNU 1.34, и zip он не читает. Вдобавок принимаетC:\...за имя удалённого хоста («Cannot connect to C: resolve failed»).unzipесть в образе и в Git Bash, но не в голой Windows; PowerShell наоборот. Поэтому распаковка глифов своя, наzlib—scripts/unzip.mjs. -
Planetiler ищет регион по идентификатору, а не по пути.
--area=russia/ south-fed-districtдаёт «No matches»; правильно--area=south-fed-district. И Java не читает переменные окружения про прокси — их надо передать системными свойствами, иначе шаг загрузки выглядит как недоступный Geofabrik. Оба места закрыты вscripts/build-tiles.mjs.
pipeline/
noise_pipeline.groovy оркестрация блоков NoiseModelling, параметры через NM_PARAMS
inspect_db.groovy дамп схем и произвольный SQL по базе прогона
rail_bench.groovy синтетическая сцена: ж/д распространение по вариантам
server/src/
index.ts HTTP-роуты и SSE
queue.ts очередь, дедупликация, разбор прогресса подпроцесса
cache.ts округление до сетки и дисковый кэш
geocode.ts прокси к HTTP Геокодеру, ключ не покидает сервер
config.ts параметры расчёта, подписи этапов, .env
web/src/
App.tsx композиция: карта, панель и связи между ними
MapCanvas.tsx карта и слой изофон
useNoiseJob.ts жизнь расчёта: запрос, прогресс, кадры, отмена
useAddressSearch.ts строка адреса и результаты геокодера
useComputedAreas.ts посчитанные области под текущей рамкой
usePanelMargin.ts измеренный отступ панели для камеры
progress.ts счётчик секунд и сглаживание прогресса
camera.ts подбор зума под диск результата
urlState.ts точка и период в адресной строке
SearchPanel.tsx форма поиска и список найденного
ProgressPanel.tsx бар, часы, кнопка отмены и пояснения
PeriodSwitch.tsx переключатель периода суток
Legend.tsx шкала уровней
basemap.ts протокол PMTiles и загрузка стиля
mapErrors.ts общее между загрузкой карты и экраном её отказа
mapTypes.ts отступы, рамка и стиль — типы без самой карты
api.ts клиент HTTP-слоя, включая SSE
palette.ts цветовая шкала уровней
scripts/
run-job.mjs CLI: координата -> GeoJSON
lib.mjs Overpass, bbox, выбор UTM-зоны, прокси
dem.mjs высоты из terrarium-тайлов -> ESRI ASCII грид
compare-runs.mjs сравнение двух прогонов по площади диапазонов
build-tiles.mjs подложка: тайлы, глифы, стиль -> TILES_DIR
unzip.mjs распаковка zip на zlib, без внешних утилит
sanity-check.mjs проверка, что экранирование не потерялось
inspect-geojson.mjs структура выхлопа: периоды, уровни, размер
smoke-api.mjs сквозная проверка HTTP-слоя
fake-job.mjs движок-заглушка: тот же протокол, никаких расчётов
check-quantize.mjs проверка идемпотентности округления
rail.mjs пути из OSM и допущения, которых в OSM нет
rail-probe.mjs что OSM знает о путях вокруг точки
rail-bench.mjs замер ж/д распространения на синтетической сцене
shared/ код, общий для сервера и скриптов
lines.mjs разбор потока на строки через границы чанков
stages.mjs список стадий расчёта, откуда выведен тип Stage
test/ юнит-тесты (node:test), гоняются в CI
.github/workflows/ci.yml типы, тесты, обе сборки, сетка, сквозная проверка API
jobs/ результаты прогонов (не в git)
cache/ отдаваемые API результаты (не в git)
.tools/ дистрибутив NoiseModelling (не в git)
- Склейка полигонов.
ST_Unionпо(PERIOD, ISOLVL)+ упрощение + округление координат: 3.6 МБ → 0.64 МБ, 3005 фичей → 41. - HTTP-слой: очередь с дедупликацией, кэш по сетке ~100 м, SSE-прогресс. Повторный клик — ~5 мс вместо ~2 минут, отдача gzip (156 КБ против 685).
- Прогрев кэша —
scripts/prewarm.mjs, пресетыmoscowиspb. - Фронтенд: React + TypeScript + MapLibre GL на своих тайлах, слой изофон, легенда в дБ(A), переключатель периодов суток.
- Проверено в браузере: изофоны рисуются поверх карты, самые громкие полосы идут вдоль проезжей части, ночь заметно тише дня.
- Поиск по адресу через Nominatim, проксируемый сервером.
- Отмена расчёта и лимиты частоты:
DELETE /api/noise/:idснимает ожидающего и убивает всё дерево процессов, когда ушёл последний; бюджеты на IP отдельные для расчётов, геокодера и остального API. - Учёт рельефа через terrarium-тайлы.
- Слой шума вместо круга по клику — см. отдельный раздел. Кэш печётся в пирамиду векторных тайлов, клик считывает уровень с карты, а расчёт запускается только за краем посчитанного.
- Ж/д шум — считает, но выключен, см. отдельный раздел. Проход распространения доведён до времени дорожного, но подвижной состав подставной, а число поездов задаёт вызывающая сторона. Трамваи невозможны в принципе: в каталоге CNOSSOS их нет.
Всё живёт в одном контейнере: Node отдаёт собранный фронтенд и API, JVM считает акустику. Отдельный статик-хостинг не нужен.
node scripts/build-tiles.mjs # один раз: подложка в ./tiles
node --max-old-space-size=6144 scripts/build-noise-tiles.mjs # слой шума из кэша
docker compose up --buildСлой шума, в отличие от подложки, Java не требует и может пересобираться прямо на сервере — а пересобирать его надо после каждого прогрева кэша, иначе новые расчёты на карте не появятся:
docker run --rm \
-v noise-map_noise-cache:/app/cache:ro \
-v /opt/noise-map/tiles:/app/tiles \
noise-map-noise-map:latest \
node --max-old-space-size=6144 scripts/build-noise-tiles.mjs --forceДве детали, на которых легко споткнуться. Кэш — именованный том
(noise-map_noise-cache), а не каталог рядом с проектом: подставить путь на
хосте не выйдет, тома там нет. И ./tiles основной сервис монтирует только на
чтение — раздавать этого достаточно, а писать нет, поэтому одноразовый
контейнер подключает тот же каталог обычным образом. Тот же приём, что и со
сборкой глифов.
Ключей передавать не нужно — их не осталось. Каталог ./tiles подключается в
контейнер только на чтение: тайлы пересобираются снаружи, а контейнер их
раздаёт. В образ они не кладутся — это сотни мегабайт, живущих своей жизнью.
Образ собран и проверен 2026-09-02. Сборка заняла 5 мин 51 с — первая, с
холодным кэшем слоёв. Образ вышел 999.6 МБ распакованными и 322.8 МБ
сжатыми: JRE 184 МБ, дистрибутив NoiseModelling 173 МБ, node_modules 12 МБ
после npm ci --omit=dev, собранный фронтенд 260 КБ.
| Замерено в контейнере | |
|---|---|
| Сборка образа | 5 мин 51 с |
| Размер образа | 999.6 МБ (322.8 МБ сжатыми) |
| Холодный расчёт | 14 мин 03 с |
| Пик памяти контейнера | 1495 МиБ при лимите 4 ГБ |
| Куча JVM | 1 ГБ — четверть лимита контейнера |
Контейнер поднимается healthy, без перезапусков и без OOM, /api/health
отвечает без поля engine, кэш читается из тома (570 файлов доехали).
Холодный расчёт доходит до конца: 45.0490,39.1230 при радиусе 1000,
maxSrcDist 500 и reflOrder 1 — 14 мин 03 с от извлечения OSM до изофон,
2.67 МБ результата. Это параметры по умолчанию run-job.mjs, а не серверные
JOB_PARAMS, и по радиусу, дальности источников и отражениям они тяжелее, так
что с замерами 354–1609 с на радиусе 750 это число сравнивать нельзя.
Три предположения, которые раньше держались на чтении, подтвердились: JRE 17
подошёл, юниксовый лаунчер в дистрибутиве на месте, npm ci --omit=dev даёт
работоспособный рантайм.
Но сборка сначала падала, и не из-за окружения: каталог shared/ не
копировался ни в одну из двух стадий, хотя config.ts и queue.ts импортируют
его относительным путём. tsc валился с тремя TS2307, и сборка вставала на
RUN npm run build:server. Исправлено двумя строками COPY shared ./shared —
по одной в каждую стадию; убирать их нельзя ни из одной, потому что в рантайме
тот же путь нужен и server/dist/queue.js, и scripts/run-job.mjs.
И нашлись две вещи, которых с хоста не видно:
- JVM берёт кучу как четверть лимита контейнера.
MaxRAMPercentage=25отmem_limit: 4gдаётMaxHeapSizeровно 1 ГБ, тогда как на хосте пик JVM был 2203 МБ. Опасение, что плотная застройка упрётся вOutOfMemoryErrorвнутри JVM, а не в лимит снаружи, проверено и снято: Тверская проходит (см. «Требования к железу» ниже). Но следствие остаётся — 4 ГБ здесь не «запас к 2.2 ГБ», а «1 ГБ кучи», и уменьшать лимит значит уменьшать кучу. - DNS внутри контейнера мигает, и рельеф этого не переживает. Один расчёт,
запущенный через сервер, умер на стадии рельефа с
getaddrinfo EAI_AGAIN s3.amazonaws.com— уже после того как Overpass отдал 7.2 МБ. Замер тут же: один отказ на шесть попыток подряд.fetchTileвdem.mjsходит за тайлом однимfetchбез ретрая, а тайлы собираются черезPromise.all, так что любой сбой DNS роняет задачу целиком. Overpass в своё время получил четыре зеркала, ротацию и бюджет; рельеф не получил ничего, потому что на хосте DNS не мигает. Исправлено:fetchTileделает четыре попытки по той же жёваной лестнице, что и Overpass, только с базой 300 мс — вся она укладывается в ~2 с. Ретраится сетевой сбой, 429, 5xx и битый PNG (обрыв на середине скачивания выглядит именно так); 404 и 403 не ретраятся, потому что тайла, которого в бакете нет, на десятой попытке не появится. Замерено на живой сети контейнера: 20 запросов, один пришёл сfetch failed (EAI_AGAIN)и стоил 229 мс вместо упавшей задачи.
Заодно выяснилось, что overpass.kumi.systems из контейнера отвечает —
7.2 МБ за 24.3 с, он и обслужил первый расчёт в Docker. С хоста он недоступен
через местный прокси и потому был помечен непроверенным; в контейнере прокси
нет, и зеркало работает.
После ретрая проверено и последнее: холодная задача, запущенная через сервер
и на серверных JOB_PARAMS, в контейнере доходит до конца — 45.1204,38.8506 за
1 мин 29 с, с предпросмотром, подметённой базой H2 и результатом в кэше под тем
же id, что вернул POST /api/noise. Плитка была разряженная (127 КБ
извлечения), так что о плотной застройке это число не говорит ничего:
проверялся путь, а не производительность.
Развёрнуто 2026-09-05: VDS Selectel 4-8-80 — Ubuntu 24.04, 4 выделенных ядра,
8 ГБ, — Docker с образом из этого репозитория, впереди Caddy с сертификатом
Let's Encrypt. Демка живёт на noisemap.online в режиме CACHE_ONLY=1:
прогретый Краснодар отдаётся, расчёт по клику не запускается.
Рабочие конфигурации сохранены образцами в deploy/ — надстройка compose и
Caddyfile, оба с объяснениями, почему сделано именно так. В git лежат только
образцы: сам docker-compose.override.yml у каждой площадки свой и потому в
.gitignore.
Замеры оттуда. Сборка образа — 41 с против 5 мин 51 с на машине разработки; разница целиком в канале, датацентр забирает 148 МБ дистрибутива и пакеты несопоставимо быстрее. Overpass отвечает за 0.09 с вместо секунд. В режиме демки контейнер занимает 24 МБ из 4 ГиБ — он просто отдаёт файлы.
Порядок, если поднимать заново: DNS на адрес сервера в режиме DNS only — серое облако, не оранжевое, иначе до Caddy дойдут адреса Cloudflare вместо адресов посетителей и лимитер посчитает всех за одного. Дальше Caddy получит сертификат сам.
Последним шагом — подложка. Раньше здесь добавлялся боевой домен в
ограничения ключа Яндекс Карт; ключа больше нет, а карта берётся из
TILES_DIR, которого на свежем сервере тоже нет. Собрать
(node scripts/build-tiles.mjs, около пяти минут и несколько гигабайт
временных файлов) и положить рядом с docker-compose.yml: каталог ./tiles
монтируется в контейнер только на чтение. Собирать можно и дома — файл
переносится как обычный, той же связкой «считать дома, отдавать с сервера»,
что и кэш. Без подложки сайт работает во всём остальном, а вместо карты
показывает текст о том, что тайлы не собраны.
Это не обычное веб-приложение: холодный расчёт занимает все ядра на минуты.
| На хосте, радиус 750 м | В контейнере, те же параметры | |
|---|---|---|
| Пик памяти | 2203 МБ (JVM) | 1630 МиБ (весь контейнер) |
| Время холодного расчёта | 354–1609 с | 1068 с на Тверской |
| Размер образа | — | 999.6 МБ, оценка «~600 МБ» была низкой |
Бесплатные тарифы с 256–512 МБ и shared CPU не подходят. Проверено на худшем
случае: Тверская — 33 891 приёмник, 7 836 источников, 3 716 зданий — в
контейнере с mem_limit: 4g проходит за 17.8 мин при пике 1630 МиБ, без единого
OutOfMemoryError. Приёмников вышло столько же, сколько на хосте (34 101), то
есть под Linux считается то же самое, а не что-то похожее.
Отсюда две поправки к прежним цифрам. Первая: 2203 МБ завышали потребность — на хосте с 32 ГБ куча по умолчанию измеряется гигабайтами, и JVM просто ленивее собирает мусор; рабочий набор укладывается в 1 ГБ. Вторая: лимит контейнера нельзя выбирать по пику процесса, потому что от него же считается куча — четверть, то есть 3 ГБ снаружи дают 768 МБ внутри. Проверены именно 4 ГБ; уложится ли Тверская в 768 МБ кучи — не замерялось.
На радиусе 500 м те же замеры давали 1836 МБ и 178–582 с: рост радиуса на
половину стоит и памяти, и времени, и это главный аргумент за CACHE_ONLY=1
на слабом хосте.
Переменная CACHE_ONLY=1 запрещает считать новое: API отдаёт заранее прогретые
места и отвечает 503 на всё остальное. Это позволяет выложить работающую демку
на слабый хост, не открывая всем желающим возможность занять сервер на десять
минут одним кликом.
Правил здесь два, и второе не менее важно первого: отказать в новом и
продолжать отвечать на всё, что ничего не запускает. Probe по-прежнему
говорит, посчитана точка или нет, /api/noise/areas по-прежнему отдаёт
посчитанные области, /api/health не отклоняется — иначе посетитель получил бы
серую карту без объяснений, что хуже отсутствия демки. Отказ приходит с
cacheOnly: true и текстом, который интерфейс показывает как есть, и случается
он до обращения к бюджету задач: ничего не списывается и ничего не
запускается.
Замерено в контейнере на прогретом Краснодаре: кэшированная точка отдаётся
(2.23 МБ), холодная получает 503, areas возвращает 267 областей, probe
отвечает по обеим. GET /api/health в этом режиме добавляет cacheOnly: true,
так что у выложенного сервера можно спросить, в каком он режиме, вместо того
чтобы выяснять это кликом.
Если перед контейнером стоит nginx, Caddy или туннель, поставьте
TRUST_PROXY=1 — и обязательно закройте опубликованный порт на loopback.
Эти две настройки работают только вместе, и вот почему.
Без TRUST_PROXY адрес берётся из сокета. Когда прокси стоит на той же
машине, в сокете всегда 127.0.0.1 — а loopback освобождён от всех лимитов
(exempt в ratelimit.ts), чтобы мог работать prewarm.mjs. То есть лимитер
не «станет общим на всех», как можно подумать, а выключится полностью, для
каждого посетителя. Под ударом в первую очередь /api/geocode: очередь к
геокодеру одна на весь сервис, и один посетитель займёт её целиком.
Обратное тоже верно: доверять X-Forwarded-For можно лишь тогда, когда
подключиться в обход прокси некому. На сервере, чей порт открыт наружу,
заголовок подделывается одной строкой, и лимит перестаёт работать вовсе.
Поэтому порт и закрывается на loopback — это не перестраховка, а условие, при
котором TRUST_PROXY безопасен.
Замерено: без TRUST_PROXY счётчик не двигался вовсе; с ним за сорок запросов
RateLimit-Remaining ушёл с 60 до 24.
Адрес сервиса в конфиге прокси пишите как 127.0.0.1, а не localhost. Там,
где localhost резолвится сначала в ::1, прокси уходит на IPv6, а Docker
публикует порт только на IPv4 — снаружи это выглядит как 502 при живом и
здоровом контейнере.
Записано, чтобы не повторять. Замерено 2026-09-04.
Домашний канал сидит за CGNAT провайдера — второй хоп трассировки
100.68.128.1 из диапазона 100.64.0.0/10. Публичного адреса у роутера нет,
поэтому проброс портов не даёт ничего в принципе. Обходится это туннелем,
который соединяется изнутри наружу, — и на этом всё останавливается.
cloudflared 2026.8.3 умеет только QUIC: транспорт HTTP/2 из него убран, в
справке tunnel run не осталось --protocol. А оператор связи QUIC режет.
Соединения регистрируются и через 10–20 секунд отваливаются с
timeout: no recent network activity — 18 регистраций и 9 разрывов за минуту.
Сайт отдаёт то 502, то 530, смотря есть ли живое соединение в момент
запроса. TCP 7844 до края при этом открыт, но воспользоваться им уже нечем.
Проверено и отброшено по пути: локальный прокси (слушает только loopback), адаптер WireGuard (маршрут по умолчанию идёт мимо него), сборка cloudflared, конфиг и служба Windows — всё это исправно, дело в транспорте.
Вывод: с этой линии Cloudflare Tunnel не заработает. Сервер с публичным адресом снимает вопрос целиком — там туннель не нужен вовсе.
Их нет. Подложка своя, геокодер по данным OSM. Сборка фронтенда идёт с пустым окружением — в CI это проверяется отдельным шагом, — и добавлять боевой домен никуда не нужно.
Что осталось от прежней схемы — исторический след в git, ниже.
Старый ключ карт остался в истории git — внутри собранного бандла, коммит
d1db820, удалён в 4da2ffd. Раньше на этом висело живое условие: ключ защищён
не секретностью, а ограничением по referer, поэтому ограничение должно было
оставаться настроенным. Теперь условия нет — ключ ничем не используется.
Отозвать его в кабинете Яндекса, когда будет удобно, и вопрос закрыт совсем.
Ключа геокодера в истории нет — он был серверным и в бандл не попадал; проверено
поиском по всем веткам.
Ветка считает от извлечения путей до совмещённой карты, но остаётся выключенной.
Код лежит за флагом --rail, выключенным по умолчанию, и к серверу не
подключён: дорожный расчёт он не затрагивает.
Раньше ветка стояла на распространении — проход не завершался за разумное время.
Теперь он завершается, и подробности ниже; выключена она уже по другой
причине, и эта причина не чинится кодом. Подвижной состав — французская
электричка SNCF, подставленная вместо ЭД4М, потому что в поставке нет ничего
другого; число поездов задаёт вызывающая сторона, потому что в OSM расписания
нет, а официальной таблицы типовых значений для железной дороги, в отличие от
автотранспорта, не существует вовсе. Трамваи невозможны в принципе. К этому
добавляется цена включения: --rail входит в JOB_PARAMS, то есть в ключ кэша,
и его включение обнуляет и демо-точки, и весь прогретый Краснодар.
В каталоге подвижного состава, который поставляется с NoiseModelling, 81
запись: 80 французских составов SNCF и один высокоскоростной тестовый поезд
(EU1, «TestTrain EU», 200 м, Vmax 300 км/ч). Трамваев, лёгкого рельса и метро
нет. Присвоить трамваю электричку — не приближение, а подмена физики: другая
длина, другое число источников, другие тормоза и диаметр колеса.
Параметры пути при этом общеевропейские (EU0…EU10 для передаточной функции,
шероховатости, стыков, мостов), так что здесь ничего выдумывать не нужно.
Замер по Комсомольской площади, радиус 850 м — 365 поверхностных путей:
| Тег | Заполнен |
|---|---|
tracks (число путей) |
0% |
maxspeed |
18% |
usage |
60% |
service (станционные пути) |
37% — исключены, там маневровая работа |
Числа поездов нет вообще, и в отличие от автотранспорта официальной таблицы типовых значений для железной дороги не существует. Поэтому интенсивность задаётся вызывающей стороной, а не выдумывается кодом.
Извлечение путей из OSM (scripts/rail.mjs), сборка RAIL_SECTIONS и
RAIL_TRAFFIC, эмиссия Railway_Emission_from_Traffic, распространение и
энергетическое сложение с дорожной картой. На Комсомольской эмиссия даёт
LW_RAILWAY из 2682 строк.
Проход распространения не завершался за разумное время: Комсомольская — 67 CPU-минут и 3 ГБ без результата, заведомо простая двухпутка — 12 CPU-минут и всё ещё считала. Диагноз здесь стоял такой: третьоктавы вместо октав плюс направленность поезда, считаемая на каждый путь, а дешёвого флага нет.
Из трёх утверждений верным оказалось одно. Всё, что ниже, меряно
pipeline/rail_bench.groovy — синтетическая сцена без OSM и без сети:
130 зданий, 4200 приёмников, maxSrcDist 350, confReflOrder 1, дифракция в
обеих плоскостях.
Одна двухпутка, 12 строк эмиссии:
| Что подаётся распространению | Время | Расхождение с точным |
|---|---|---|
| дорожный проход по той же линии | 4.5 с | — |
LW_RAILWAY как есть: 24 полосы, 12 строк |
18.4 с | точный ответ |
| без пустых строк: 24 полосы, 4 строки | 8.7 с | ровно 0 |
| + свёрнуто в октавы: 8 полос, 4 строки | 5.4 с | −0.034 дБ, худшее 0.060 |
| без направленности: 24 полосы, 12 строк | 25.0 с | +1.520 дБ, худшее 20.0 |
| без направленности, октавы, 4 строки | 5.4 с | +1.484 дБ, худшее 20.0 |
Отсюда три вывода, и два из них противоречат прежнему диагнозу.
Дорого стоили строки, а не полосы. На каждый отрезок пути эмиссия заводит все шесть источников CNOSSOS — качение, две тяги, две аэродинамики и мост — вне зависимости от того, излучает ли что-нибудь конкретно этот поезд на этом пути. Пригородная электричка на 100 км/ч даёт качение 73 дБ, тягу на 30 дБ ниже, ровно ничего аэродинамического (−137 дБ, то есть заполнитель) и никакого моста. Четыре строки из шести — полная доля работы распространения ради вклада, которого нет. Выбросить их стоит втрое при побитово том же ответе.
Третьоктавы стоили в 1.4 раза, а не в три, и свернуть их в октавы стоит
0.06 дБ, а не «изменения работы направленности». Полос ровно по три на октаву,
поэтому энергетическая сумма тройки — это в точности октавная полоса; а сама
направленность зависит от частоты через log10((f+600)/200), и разница между
центром октавы и центрами её третьоктав теряется в сотых долях децибела. Что
измерено, то и получилось.
Направленность не стоит ничего — она экономит. Без неё проход медленнее
(25.0 с против 18.4), потому что уровни выше и до приёмника доживает больше
лучей. И врёт: 1.5 дБ в среднем и 20 дБ у самого пути. Поэтому DIR_ID
переносится в таблицу источников как есть.
Ускорение растёт вместе с плотностью путей, потому что стоимость сверхлинейна по числу строк. Станционная горловина, 30 линий и 180 строк эмиссии, та же сцена:
| Что подаётся | Время |
|---|---|
LW_RAILWAY как есть: 24 полосы, 180 строк |
1702.4 с |
| без пустых строк: 24 полосы, 60 строк | 298.7 с |
| + октавы: 8 полос, 60 строк | 269.6 с |
То есть 28.4 минуты против 4.5 — в 6.3 раза, при расхождении 0.029 дБ в среднем и 0.053 дБ худшем. Дорожный проход по той же сцене — 4.6 с.
Оба преобразования делает buildRailSources в pipeline/noise_pipeline.groovy.
Railway_Emission_from_Traffic теряет последний отрезок таблицы.
RailWayLWIterator читает с забеганием вперёд: запись накапливается, пока идёт
тот же ключ, и отдаётся, когда встретился следующий. Когда строки кончились,
встречать нечего — и накопленная запись пропадает молча. Замерено: 30 отрезков
на входе, 29 на выходе, потерян ровно IDSECTION 30. На таблице из одного
отрезка ошибки нет, потому что там срабатывает другая ветка того же метода, —
поэтому баг и не был виден на пробных прогонах. railEmission дописывает в
конец дубликат последнего отрезка: съедается он, а настоящий доходит; дубликат
удаляется сразу после эмиссии.
Складывать дорогу с железной дорогой можно было только по имени колонки.
Совмещение идёт поколоночно, а пока ж/д шла третьоктавами, её HZ63 означала
третьоктаву 63 Гц, а дорожная — октаву: имена совпадали, величины — нет, и сумма
получалась не той. Сворачивание в октавы закрывает и это; combineRoadRail и
buildRailSources теперь пара, и расходиться им нельзя.
node scripts/rail-bench.mjs # прогон по умолчанию
node scripts/rail-bench.mjs --sections 30 --variants road,railProd,rail3pipeline/rail_bench.groovy строит сцену сам — здания, пути, сетку приёмников —
и потому не требует ни Overpass, ни четверти часа: настоящая ж/д задача стоит и
того, и другого, и работать над веткой, платя эту цену за каждый эксперимент,
нельзя. Считает он не копию пайплайна, а его собственные railEmission,
buildRailSources и combineRoadRail, так что изменение в пайплайне сразу
видно в замере. Вариант rail3 — точный эталон, с которым сравниваются
остальные; он медленный, и в списке его стоит держать последним.
Грабли, которые бенчмарк нашёл первым делом: Delaunay_Grid считает fence
без SRID заданным в WGS84 и перепроецирует его. Метрический WKT превращается в
охват шириной в миллионы метров и сетку из 16.7 млн ячеек — снаружи это выглядит
зависанием, а на диске за десять минут вырастает база на 10 ГБ. Передавать надо
EWKT (SRID=32637;POLYGON(...)) или WGS84, но не голый метрический WKT.
scripts/rail-probe.mjs— сколько OSM знает о путях вокруг точки и каких тегов не хватает;pipeline/inspect_db.groovy— дамп схемы таблиц из базы прогона. Именно им выяснилось, чтоLW_RAILWAYсамодостаточна и передавать её надо какtableSources, а не как отдельную таблицу эмиссии.
Код этого репозитория — под MIT, полный текст в LICENSE. Всё остальное живёт по своим условиям и от MIT их не наследует:
- NoiseModelling — GPLv3. Запускается отдельным процессом через CLI, а не линкуется как библиотека, поэтому копилефт не распространяется на код этого проекта.
- Данные OpenStreetMap — ODbL, требуется атрибуция © OpenStreetMap contributors.
- MapLibre GL JS — BSD-3-Clause. Planetiler — Apache 2.0, запускается отдельным процессом на этапе сборки тайлов и в поставку не входит. Глифы Noto Sans — SIL Open Font License 1.1.
- Подложка и поиск по адресу — те же данные OSM, то есть ODbL и та же атрибуция. Она есть и в панели интерфейса, и в стиле карты, и скрывать её нельзя.
Яндекс Карт в проекте больше нет. Их условия — бесплатное использование только в открытых некоммерческих проектах и запрет сохранять полученные через API данные — были одной из двух причин ухода; второй был суточный лимит примерно в сотню загрузок карты, то есть в сотню посетителей. Пробки Яндекса не использовались и раньше, по причинам из раздела про трафик.
Результат — расчётная оценка на основе типовых значений трафика, а не результат измерений. Не предназначен для экспертизы, юридических споров или принятия решений о здоровье.
