"""Серверный статик-рендер карт для 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` — подстановка ```` вместо строки-плейсхолдера в готовом 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 = ( '
Карта недоступна — геоданные участка отсутствуют ' "в отчёте
" ) # ── Парсинг входных геометрий (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 на ```` с 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'Карта участка' ) 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)