gendesign/backend/app/services/exporters/report_maps.py
bot-backend 0b3ec7f91b
All checks were successful
Deploy / deploy (push) Successful in 1m59s
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 3m47s
Deploy / build-worker (push) Successful in 4m45s
feat(exporters): серверный статик-рендер карт для PDF-отчёта (#2259 PR-C Maps) (#2286)
2026-07-03 10:29:23 +00:00

386 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Серверный статик-рендер карт для PDF-отчёта ПТИЦА (эпик #2259 PR-C).
Рендерит два вида PNG-карт БЕЗ браузера (headless matplotlib + geopandas + contextily):
• :func:`render_parcel_map_png` — карта участка (§1/§3): полигон участка (заливка +
контур), поверх — риск-зоны НСПД / красные линии / ЗОУИТ-пересечения (если геометрии
присутствуют в persist-payload) + конкуренты точками. Подложка — OSM-тайлы через
contextily; auto-bbox = участок + буфер.
• :func:`render_concept_footprint_png` — карта концепции (§7): контур участка + пятна
застройки (footprint'ы корпусов из ``ConceptVariant.buildings_geojson``) с подписью
этажности (``properties.floors``).
Плюс :func:`embed_map_png` — подстановка ``<img src="data:image/png;base64,…">`` вместо
строки-плейсхолдера в готовом HTML (``png=None`` → плашка «карта недоступна»).
CRS-контракт: все входные геометрии — EPSG:4326 (GeoJSON ``geom_geojson`` /
``geometry_geojson``, WKT ``geom_wkt`` в 4326, конкуренты lat/lng). Рисуем в Web Mercator
(EPSG:3857), т.к. OSM-тайлы contextily идут в 3857 — иначе подложка не совпадёт со слоями.
GRACEFUL: нет ``geom_geojson`` → ``None`` (нечего рисовать). Тайлы OSM недоступны/таймаут →
белый фон + слои, PNG всё равно возвращается (никогда не роняем экспорт из-за сети).
Битые отдельные геометрии слоёв пропускаются с ``logger.warning``, не валят всю карту.
HEADLESS: ``matplotlib.use("Agg")`` вызывается на уровне модуля ДО импорта pyplot — импорт
``report_maps`` не требует X11/display (запускается в Celery-воркере/Docker без GUI).
"""
from __future__ import annotations
import base64
import io
import logging
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FuturesTimeoutError
from contextlib import contextmanager
from typing import Any
import matplotlib
# Headless-бэкенд ДО импорта pyplot — модуль импортируется в Celery-воркере без display.
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.figure import Figure
from shapely.geometry import Point, shape
from shapely.geometry.base import BaseGeometry
logger = logging.getLogger(__name__)
# ── Константы рендера ──────────────────────────────────────────────────────────
_WGS84 = "EPSG:4326"
_WEB_MERCATOR = "EPSG:3857"
# Буфер вокруг участка при auto-bbox (доля от большей стороны bbox). Даёт «воздух»
# по краям, чтобы контур участка не липнул к рамке и был виден контекст подложки.
_BBOX_PAD_FRAC: float = 0.25
# Таймаут ожидания OSM-тайлов (сек). Тайлы тянутся синхронно внутри contextily —
# оборачиваем в поток с bounded-таймаутом, чтобы медленный/недоступный tile-server
# не подвешивал воркер. Превышение → graceful fallback на белый фон.
_BASEMAP_TIMEOUT_S: float = 10.0
# Базовый физический размер фигуры (дюймы). Итоговый пиксель-размер = fig_inches * dpi,
# ограничивается сверху ``max_px`` пересчётом dpi (см. ``_figure_to_png``).
_FIG_INCHES: float = 6.4
# Палитра слоёв (edgecolor, facecolor, alpha) — консистентно с семантикой отчёта.
_PARCEL_STYLE = {"edgecolor": "#1d4ed8", "facecolor": "#3b82f6", "alpha": 0.28, "linewidth": 1.8}
_RISK_STYLE = {"edgecolor": "#b91c1c", "facecolor": "#ef4444", "alpha": 0.30, "linewidth": 1.0}
_REDLINE_STYLE = {"color": "#dc2626", "linewidth": 1.6, "linestyle": "--"}
_ZOUIT_STYLE = {"edgecolor": "#b45309", "facecolor": "#f59e0b", "alpha": 0.22, "linewidth": 1.0}
_OPPORTUNITY_STYLE = {
"edgecolor": "#15803d",
"facecolor": "#22c55e",
"alpha": 0.20,
"linewidth": 0.9,
}
_COMPETITOR_STYLE = {"color": "#7c3aed", "marker": "o", "markersize": 5.0, "linestyle": "none"}
_CONCEPT_FOOTPRINT_STYLE = {
"edgecolor": "#0f766e",
"facecolor": "#14b8a6",
"alpha": 0.55,
"linewidth": 1.2,
}
_MAP_UNAVAILABLE_HTML = (
'<div class="map-placeholder">Карта недоступна — геоданные участка отсутствуют '
"в отчёте</div>"
)
# ── Парсинг входных геометрий (EPSG:4326) ──────────────────────────────────────
def _geom_from_geojson(geojson: dict[str, Any] | None) -> BaseGeometry | None:
"""GeoJSON-geometry dict → shapely-geометрия (EPSG:4326). Битый вход → None+warning."""
if not isinstance(geojson, dict):
return None
try:
geom = shape(geojson)
except Exception as exc: # любой битый GeoJSON: пропустить слой, не валить всю карту
logger.warning("report_maps: не разобрал GeoJSON-геометрию: %s", exc)
return None
if geom.is_empty:
return None
return geom
def _geom_from_wkt(wkt: str | None) -> BaseGeometry | None:
"""WKT-строка (EPSG:4326) → shapely-геометрия. Битый вход → None+warning."""
if not wkt or not isinstance(wkt, str):
return None
try:
from shapely import wkt as shapely_wkt
geom = shapely_wkt.loads(wkt)
except Exception as exc: # битый WKT слоя: пропустить геометрию, не валить всю карту
logger.warning("report_maps: не разобрал WKT-геометрию: %s", exc)
return None
if geom.is_empty:
return None
return geom
def _to_mercator(geoms: list[BaseGeometry]) -> Any:
"""Список shapely-геометрий (EPSG:4326) → GeoSeries в Web Mercator (EPSG:3857).
Импорт geopandas — локальный (тяжёлый), вызывается только когда есть что рисовать.
"""
import geopandas as gpd
series = gpd.GeoSeries(geoms, crs=_WGS84)
return series.to_crs(_WEB_MERCATOR)
# ── Basemap (OSM-тайлы) с bounded-таймаутом и graceful fallback ────────────────
def _add_basemap(ax: Any) -> bool:
"""Подложить OSM-тайлы под слои с таймаутом. True — легли, False — fallback (белый фон).
contextily тянет тайлы синхронно (сеть) — оборачиваем в поток с таймаутом, чтобы
недоступный tile-сервер не подвешивал воркер. Любая ошибка/таймаут → белый фон,
слои остаются видны, PNG всё равно рендерится.
"""
import contextily as cx
def _fetch() -> None:
cx.add_basemap(ax, crs=_WEB_MERCATOR, source=cx.providers.OpenStreetMap.Mapnik)
try:
with ThreadPoolExecutor(max_workers=1) as pool:
pool.submit(_fetch).result(timeout=_BASEMAP_TIMEOUT_S)
return True
except FuturesTimeoutError:
logger.warning(
"report_maps: OSM basemap timeout (%.0fs) — fallback белый фон", _BASEMAP_TIMEOUT_S
)
return False
except Exception as exc: # тайлы недоступны: graceful fallback на белый фон, не валим экспорт
logger.warning("report_maps: OSM basemap недоступен (%s) — fallback белый фон", exc)
return False
# ── Общие хелперы фигуры ───────────────────────────────────────────────────────
@contextmanager
def _managed_figure() -> Iterator[tuple[Figure, Any]]:
"""Контекст-менеджер фигуры: создать axes, ГАРАНТИРОВАННО закрыть в finally.
pyplot держит созданные фигуры в глобальном реестре (``plt.get_fignums()``) до
явного ``plt.close``. В долгоживущем Celery-воркере любое исключение между
созданием фигуры и ``savefig`` без close оставляло бы residual-окно → растущий
RSS (утечка на 1 фигуру за каждый упавший рендер). ``finally`` закрывает фигуру
на ЛЮБОМ пути выхода — успех, per-geometry-ошибка, неожиданный raise.
"""
fig = plt.figure(figsize=(_FIG_INCHES, _FIG_INCHES))
try:
ax = fig.add_subplot(1, 1, 1)
ax.set_axis_off()
ax.set_aspect("equal")
yield fig, ax
finally:
plt.close(fig)
def _apply_bbox(ax: Any, parcel_merc: Any) -> None:
"""Выставить границы axes по bbox участка + буфер (``_BBOX_PAD_FRAC``)."""
minx, miny, maxx, maxy = parcel_merc.total_bounds
span = max(maxx - minx, maxy - miny) or 1.0
pad = span * _BBOX_PAD_FRAC
ax.set_xlim(minx - pad, maxx + pad)
ax.set_ylim(miny - pad, maxy + pad)
def _figure_to_png(fig: Figure, max_px: int) -> bytes:
"""Фигуру → PNG-bytes с dpi, подобранным так, чтобы длинная сторона ≤ ``max_px``.
Закрытие фигуры — НЕ здесь: им владеет ``_managed_figure`` (finally покрывает и
путь, где ``savefig`` сам кинул исключение).
"""
dpi = max(72.0, min(300.0, float(max_px) / _FIG_INCHES))
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", pad_inches=0.05)
return buf.getvalue()
# ── Публичный API ──────────────────────────────────────────────────────────────
def render_parcel_map_png(analyze_result: dict[str, Any], *, max_px: int = 1600) -> bytes | None:
"""Карта участка + слои НСПД + конкуренты → PNG-bytes. Нет ``geom_geojson`` → None.
Args:
analyze_result: persist-payload ``analysis_runs.result`` (schema analyze-1.0).
Читаемые ключи с геометрией: ``geom_geojson`` (обязателен), ``nspd_risk_zones``,
``nspd_red_lines``, ``nspd_opportunity_parcels`` (WKT ``geom_wkt``),
``nspd_zouit_overlaps`` (``geometry_geojson``), ``competitors`` (``lat``/``lng``).
max_px: верхняя граница длинной стороны PNG в пикселях.
Returns:
PNG-bytes, либо None если геометрии участка нет (нечего рисовать).
"""
parcel = _geom_from_geojson(analyze_result.get("geom_geojson"))
if parcel is None:
return None
with _managed_figure() as (fig, ax):
# Участок — базовый слой, задаёт bbox.
parcel_merc = _to_mercator([parcel])
parcel_merc.plot(ax=ax, **_PARCEL_STYLE)
_apply_bbox(ax, parcel_merc)
# Риск-зоны (WKT).
_plot_wkt_layer(ax, analyze_result.get("nspd_risk_zones"), _RISK_STYLE)
# ЗОУИТ-пересечения (GeoJSON geometry_geojson).
_plot_geojson_layer(ax, analyze_result.get("nspd_zouit_overlaps"), _ZOUIT_STYLE)
# Opportunity-участки (WKT).
_plot_wkt_layer(ax, analyze_result.get("nspd_opportunity_parcels"), _OPPORTUNITY_STYLE)
# Красные линии (WKT, линии — без заливки: _REDLINE_STYLE несёт color, не facecolor).
_plot_wkt_layer(ax, analyze_result.get("nspd_red_lines"), _REDLINE_STYLE)
# Конкуренты — точки lat/lng.
_plot_competitors(ax, analyze_result.get("competitors"))
_add_basemap(ax) # graceful: False → просто белый фон, PNG всё равно вернётся
return _figure_to_png(fig, max_px)
def render_concept_footprint_png(
concept_variant: dict[str, Any],
parcel_geojson: dict[str, Any] | None,
*,
max_px: int = 1600,
) -> bytes | None:
"""Карта концепции: контур участка + пятна корпусов с подписью этажности → PNG-bytes.
Args:
concept_variant: dict-представление ``ConceptVariant`` с ключом ``buildings_geojson``
(FeatureCollection, ``properties.floors`` / ``footprint_sqm`` / ``section_id``).
parcel_geojson: GeoJSON-геометрия участка (контур-подложка). None → рисуем только
корпуса (bbox по ним).
max_px: верхняя граница длинной стороны PNG в пикселях.
Returns:
PNG-bytes, либо None если нет ни участка, ни корпусов (нечего рисовать).
"""
fc = concept_variant.get("buildings_geojson") if isinstance(concept_variant, dict) else None
features = fc.get("features", []) if isinstance(fc, dict) else []
footprints: list[BaseGeometry] = []
floors_labels: list[int | None] = []
for feat in features:
if not isinstance(feat, dict):
continue
geom = _geom_from_geojson(feat.get("geometry"))
if geom is None:
continue
footprints.append(geom)
props = feat.get("properties") or {}
raw_floors = props.get("floors") if isinstance(props, dict) else None
floors_labels.append(int(raw_floors) if isinstance(raw_floors, int | float) else None)
parcel = _geom_from_geojson(parcel_geojson)
if parcel is None and not footprints:
return None
with _managed_figure() as (fig, ax):
# bbox: по участку если есть, иначе по совокупности footprint'ов.
bbox_source = _to_mercator([parcel]) if parcel is not None else _to_mercator(footprints)
if parcel is not None:
# Участок — только контур (без заливки), корпуса рисуются поверх.
_to_mercator([parcel]).plot(ax=ax, facecolor="none", edgecolor="#1d4ed8", linewidth=1.8)
if footprints:
fp_merc = _to_mercator(footprints)
fp_merc.plot(ax=ax, **_CONCEPT_FOOTPRINT_STYLE)
# Подпись этажности в центроиде каждого корпуса.
for geom_merc, floors in zip(fp_merc.geometry, floors_labels, strict=True):
if floors is None:
continue
c = geom_merc.centroid
ax.annotate(
f"{floors} эт.",
(c.x, c.y),
ha="center",
va="center",
fontsize=7.0,
color="#0f172a",
)
_apply_bbox(ax, bbox_source)
_add_basemap(ax)
return _figure_to_png(fig, max_px)
def embed_map_png(html: str, placeholder: str, png: bytes | None) -> str:
"""Заменить строку-``placeholder`` в HTML на ``<img>`` с data-URI PNG.
``png=None`` → плашка «карта недоступна» (тот же класс ``.map-placeholder``, что и
в full_report_html — стиль уже есть в шаблоне).
"""
if png is None:
return html.replace(placeholder, _MAP_UNAVAILABLE_HTML)
b64 = base64.b64encode(png).decode("ascii")
img = (
f'<img class="report-map" src="data:image/png;base64,{b64}" '
'style="width:100%;max-width:100%;height:auto;display:block;margin:8pt 0;'
'border:1px solid #e5e7eb;border-radius:6pt;" alt="Карта участка" />'
)
return html.replace(placeholder, img)
# ── Приватные слой-плоттеры ────────────────────────────────────────────────────
def _plot_wkt_layer(ax: Any, items: Any, style: dict[str, Any]) -> None:
"""Отрисовать слой из списка dict'ов с ключом ``geom_wkt`` (EPSG:4326).
Заливка/линия задаётся ``style`` (наличие ``facecolor`` → полигон, ``color`` без
``facecolor`` → линия) — geopandas ``plot`` разбирает это сам по типу геометрии.
"""
geoms = _collect_wkt_geoms(items)
if not geoms:
return
_to_mercator(geoms).plot(ax=ax, **style)
def _plot_geojson_layer(ax: Any, items: Any, style: dict[str, Any]) -> None:
"""Отрисовать слой из списка dict'ов с ключом ``geometry_geojson`` (EPSG:4326)."""
if not isinstance(items, list):
return
geoms = [
g
for it in items
if isinstance(it, dict)
and (g := _geom_from_geojson(it.get("geometry_geojson"))) is not None
]
if not geoms:
return
_to_mercator(geoms).plot(ax=ax, **style)
def _collect_wkt_geoms(items: Any) -> list[BaseGeometry]:
"""Собрать shapely-геометрии из списка dict'ов по ключу ``geom_wkt``."""
if not isinstance(items, list):
return []
out: list[BaseGeometry] = []
for it in items:
if not isinstance(it, dict):
continue
geom = _geom_from_wkt(it.get("geom_wkt"))
if geom is not None:
out.append(geom)
return out
def _plot_competitors(ax: Any, competitors: Any) -> None:
"""Отрисовать конкурентов точками из ``lat``/``lng`` (EPSG:4326).
Внимание: у ``Competitor`` долгота в ключе ``lng`` (не ``lon``) — см. schemas/parcel.py.
"""
if not isinstance(competitors, list):
return
pts: list[BaseGeometry] = []
for c in competitors:
if not isinstance(c, dict):
continue
lat, lng = c.get("lat"), c.get("lng")
if isinstance(lat, int | float) and isinstance(lng, int | float):
pts.append(Point(float(lng), float(lat)))
if not pts:
return
_to_mercator(pts).plot(ax=ax, **_COMPETITOR_STYLE)