Skip to content

Repository files navigation

noise-map

Карта шумового загрязнения от автотранспорта для произвольной точки на карте. Пользователь кликает по адресу — сервис считает уровни шума вокруг него по европейскому стандарту CNOSSOS-EU и рисует изофоны поверх своей карты, собранной из тех же данных OpenStreetMap.

Расчёт идёт по реальной физике распространения звука: учитываются экранирование зданиями, дифракция, отражения от фасадов, поглощение грунтом и атмосферой. Это не тепловая карта «расстояние до ближайшей дороги» — двор за домом действительно выходит тише улицы, и это проверено (см. Проверка правдоподобности).

Живое демо: noisemap.online — прогретый Краснодар в режиме CACHE_ONLY: карта, изофоны и переключение периодов суток на месте, а расчёт по клику для новых точек выключен, чтобы демка не занимала все ядра сервера на четверть часа (см. Боевой сервер).

Изофоны шума вокруг Тверской улицы в Москве

Lden вокруг Тверской улицы. Тёмная полоса — 70–80 дБ(A) вдоль проезжей части, оранжевый растекается вглубь кварталов, за домами уровень падает. Круглая граница — зона показа радиусом 750 м; масштаб карта подобрала под неё сама.


Оглавление


Статус

Расчётное ядро готово и проверено. Сквозной пайплайн от координаты до 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 — и что с ним не так

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 DEN
node 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 на более дорогой путь расчёта профилей вдоль лучей. Поэтому используется подробная сетка — она обходится бесплатно.


HTTP-слой

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.

Подметание базы H2

База удаляется дважды, и это два разных требования. Перед прогоном — ради правильности: 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 server

scripts/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 проксируется на :8787

React + 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. Отсюда три правила, которые нельзя нарушать при правках.

  1. Всё ненасыщенное. Цветной фон под полупрозрачной палитрой смещает читаемый уровень шума — карта начинает врать цветом.
  2. Дороги белые с серой обводкой и читаются сквозь заливку. Карта именно про них.
  3. Здания рисуются с 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 минут» — обе границы измерены, а не выдуманы.

Три рычага, отсортированные по эффекту:

  1. fence для приёмников — считать зону показа, а не весь bbox выгрузки. Даёт около 3×: квадрат вокруг источников (сторона 2·850 м) втрое больше квадрата вокруг зоны показа (2·500 м). Именно квадрата: Delaunay_Grid берёт от переданного полигона только его envelope, поэтому кругом эта область не была никогда — круг появляется в самом конце, обрезкой изофон.
  2. Горизонтальная дифракция — обход звука вокруг углов зданий. В плотной застройке комбинаторно дорог, даёт ~1.6×, а на картину влияет умеренно.
  3. 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 м на той же точке.

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


Грабли

Выяснено экспериментально, из документации не следует.

  1. Сигнатуры exec у блоков различаются. Import_OSM, Delaunay_Grid, Change_SRID принимают (Connection, input). У Noise_level_from_traffic, Create_Isosurface, Export_Table есть ещё перегрузка с ProgressVisitor. Лишний третий аргумент даёт MissingMethodException.

  2. Нужен Delaunay_Grid, а не Regular_Grid. Create_Isosurface потребляет таблицу TRIANGLES, которую создаёт только Delaunay.

  3. Дифракция выключена по умолчанию. При confDiffVertical: false двор получается таким же шумным, как улица. Включать обязательно — иначе теряется весь смысл использования CNOSSOS.

  4. confMaxSrcDist по умолчанию 150 м. Дальние магистрали просто выпадают из расчёта, и это никак не сигнализируется.

  5. Приёмники ограничиваются через fence, который принимает EWKT-строку и сам перепроецирует её в рабочую систему координат, беря envelope.

  6. Результат выходит в метрической проекции (UTM по долготе точки — расчёт требует метров), для веба нужен Change_SRID в EPSG:4326.

  7. Node 22 не читает HTTPS_PROXY для fetch. На машине за прокси Overpass выглядит полностью недоступным — UND_ERR_CONNECT_TIMEOUT вместо внятной ошибки. Лечится ProxyAgent из undici, см. scripts/lib.mjs. NODE_USE_ENV_PROXY появился только в Node 24.

  8. Публичный Overpass регулярно отдаёт 504. Одна попытка — не показатель доступности. Реализованы ретраи с бэкоффом по нескольким зеркалам, включая российское maps.mail.ru.

  9. SSE-слушатель срабатывает синхронно при подписке. Если задача уже завершена, обработчик выполняется внутри вызова subscribe, когда его собственный результат ещё не присвоен — обращение к такой переменной падает с ReferenceError из временной мёртвой зоны. Заголовки к этому моменту отправлены, поэтому общий catch пытался записать ответ повторно и ронял весь процесс. Ничто, к чему обращается слушатель, не должно объявляться после него, а catch обязан проверять res.headersSent.

  10. Лаунчер движка называется по-разному на Windows и Linux. Код запускал bin/ScriptRunner.bat, которого в linux-образе нет. Такие вещи не ловятся ни типами, ни тестами на машине разработчика — только попыткой представить себе среду запуска.

  11. Number(null) и Number('') равны нулю, а ноль — валидная координата. Разбор ?lat=&lon= без проверки на наличие параметров отправлял обычный адрес без параметров в Гвинейский залив и запускал там расчёт.

  12. Округление до сетки должно быть идемпотентным. Шаг по долготе зависит от широты, и если считать его от исходной широты, то повторное округление уже округлённой точки даёт чуть другой шаг — рядом с границей ячейки это перекидывает точку в соседнюю. Одно место получало бы два разных ключа кэша. Лечится вычислением шага по долготе от уже округлённой широты; scripts/check-quantize.mjs проверяет инвариант на ~88 тысячах точек от Сочи до Мурманска.

  13. У пустой таблицы 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), поэтому пайплайн передаёт таблицу только когда в ней что-то есть; движок берёт своё поглощение по умолчанию — ровно то, что и означает пустая таблица. Подсовывать фиктивный полигон нельзя: он изменил бы акустику, сказав то, чего в данных не было.

  14. Один нечитаемый тег 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) пустую строку проверяют — поэтому это дефект данных, а не настройки.

  15. MapLibre вычисляет адрес своего воркера в рантайме. Он собирает его из import.meta.url, поэтому статически его не видит ни один сборщик: файл не попадает в сборку, карта на живом сервере просит /assets/maplibre-gl-worker.mjs, получает index.html (откат статики) и падает на проверке MIME-типа. Молча — в интерфейсе просто пустое поле, а единственный след в консоли говорит про MIME, а не про карту. Лечится явным импортом ?worker&url и setWorkerUrl, см. web/src/basemap.ts. Одного ?url мало: воркер импортирует общий чанк, и его надо собрать, а не скопировать.

  16. tar в Git Bash — GNU 1.34, и zip он не читает. Вдобавок принимает C:\... за имя удалённого хоста («Cannot connect to C: resolve failed»). unzip есть в образе и в Git Bash, но не в голой Windows; PowerShell наоборот. Поэтому распаковка глифов своя, на zlibscripts/unzip.mjs.

  17. 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 для передаточной функции, шероховатости, стыков, мостов), так что здесь ничего выдумывать не нужно.

Чего нет в OpenStreetMap

Замер по Комсомольской площади, радиус 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,rail3

pipeline/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 данные — были одной из двух причин ухода; второй был суточный лимит примерно в сотню загрузок карты, то есть в сотню посетителей. Пробки Яндекса не использовались и раньше, по причинам из раздела про трафик.

Дисклеймер

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

About

Road traffic noise map for any point on the map: OpenStreetMap data, CNOSSOS-EU acoustic model, noise isophones on Yandex Maps. / Карта шумового загрязнения от автотранспорта для любой точки на карте: данные OpenStreetMap, расчёт по CNOSSOS-EU, изофоны шума на Яндекс Картах.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages