Merge pull request 'feat(nspd): TIER 3 risk zones (#94 PR1 of 4)' (#217) from feat/94-pr1-risk-zones into main
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m37s
Deploy / build-worker (push) Successful in 2m36s
Deploy / build-frontend (push) Successful in 2m6s
Deploy / deploy (push) Successful in 41s

This commit is contained in:
lekss361 2026-05-16 15:03:18 +00:00
commit e0055d299c
9 changed files with 545 additions and 3 deletions

View file

@ -24,6 +24,7 @@ from app.schemas.parcel import (
ParcelDetail,
ParcelSearchRequest,
ParcelSearchResponse,
RiskZone,
)
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
from app.services.site_finder.best_layouts import get_best_layouts
@ -2063,10 +2064,12 @@ def analyze_parcel(
# nspd_zoning: ПЗЗ зона из territorial_zones кеша (G1)
# nspd_zouit_overlaps: ЗОУИТ пересечения (G3)
# nspd_engineering_nearby: инженерные сооружения в 200м (I3)
# nspd_risk_zones: TIER 3 risk zones (#94) — подтопление, эрозия, гари, оползни
# nspd_dump: freshness metadata — available, stale, harvest_triggered
"nspd_zoning": nspd_dump_data["nspd_zoning"],
"nspd_zouit_overlaps": nspd_dump_data["nspd_zouit_overlaps"],
"nspd_engineering_nearby": nspd_dump_data["nspd_engineering_nearby"],
"nspd_risk_zones": [RiskZone(**rz) for rz in nspd_dump_data.get("nspd_risk_zones", [])],
"nspd_dump": nspd_dump_data["nspd_dump"],
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner
"gate_verdict": compute_gate_verdict(

View file

@ -68,6 +68,18 @@ class ConnectionPointsResponse(BaseModel):
dump_fetched_at: str | None
# ── NSPD Risk Zones schemas (issue #94 TIER 3) ───────────────────────────────
class RiskZone(BaseModel):
"""Одна риск-зона НСПД (TIER 3), пересекающая участок."""
layer: str # e.g. "risk_flooding", "risk_landslide"
subtype: str | None # человекочитаемое название из NSPD properties
geom_wkt: str | None # WKT геометрии risk-зоны в EPSG:4326
intersection_area_sqm: float | None # площадь пересечения с участком, м²
# ── Parcel search/detail schemas ──────────────────────────────────────────────

View file

@ -15,6 +15,7 @@ from __future__ import annotations
import json
import logging
import math
from datetime import UTC, datetime, timedelta
from typing import Any
@ -37,6 +38,7 @@ EMPTY_DUMP_RESULT: dict[str, Any] = {
"nspd_zoning": None,
"nspd_zouit_overlaps": [],
"nspd_engineering_nearby": [],
"nspd_risk_zones": [],
"nspd_dump": {
"available": False,
"fetched_at_utc": None,
@ -62,6 +64,7 @@ def make_empty_result(
"nspd_zoning": None,
"nspd_zouit_overlaps": [],
"nspd_engineering_nearby": [],
"nspd_risk_zones": [],
"nspd_dump": {
"available": False,
"fetched_at_utc": fetched_at_utc,
@ -124,7 +127,8 @@ def get_quarter_dump_data(
harvest_error,
territorial_zones_count,
zouit_count,
engineering_count
engineering_count,
risks_count
FROM nspd_quarter_dumps
WHERE quarter_cad = :q
"""
@ -150,6 +154,7 @@ def get_quarter_dump_data(
territorial_zones_count: int = row[4] or 0
zouit_count: int = row[5] or 0
engineering_count: int = row[6] or 0
risks_count: int = row[7] or 0
is_stale = (now - fetched_at) > max_age
has_error = harvest_error is not None
@ -179,6 +184,7 @@ def get_quarter_dump_data(
"nspd_zoning": None,
"nspd_zouit_overlaps": [],
"nspd_engineering_nearby": [],
"nspd_risk_zones": [],
"nspd_dump": dump_meta,
}
@ -186,15 +192,18 @@ def get_quarter_dump_data(
"territorial_zones_count": territorial_zones_count,
"zouit_count": zouit_count,
"engineering_count": engineering_count,
"risks_count": risks_count,
}
nspd_zoning = _get_zoning(db, quarter, parcel_wkt, layer_counts)
nspd_zouit = _get_zouit_overlaps(db, quarter, parcel_wkt, layer_counts)
nspd_engineering = _get_engineering_nearby(db, quarter, parcel_wkt, layer_counts)
nspd_risk_zones = _get_risk_zones(db, quarter, parcel_wkt, layer_counts)
return {
"nspd_zoning": nspd_zoning,
"nspd_zouit_overlaps": nspd_zouit,
"nspd_engineering_nearby": nspd_engineering,
"nspd_risk_zones": nspd_risk_zones,
"nspd_dump": dump_meta,
}
@ -394,6 +403,152 @@ def _get_engineering_nearby(
return []
# ── Risk zones (issue #94 TIER 3) ────────────────────────────────────────────
# Human-readable subtype labels for risk layer keys.
# Keys match NSPDClient.QUARTER_RISK_LAYERS short_names.
_RISK_SUBTYPE_LABELS: dict[str, str] = {
"flooding_underground": "Подтопление",
"flooding": "Затопление",
"swampification": "Заболачивание",
"landslide": "Обвально-осыпные процессы",
"abrasion": "Абразия",
"erosion_water": "Водная эрозия",
"erosion_linear": "Линейная эрозия",
"erosion_wind": "Ветровая эрозия",
"desertification": "Опустынивание",
"clutter": "Захламление",
"burns": "Гари",
}
def _extract_features_by_layer(
features: list[dict[str, Any]],
layer_prefix: str,
) -> list[dict[str, Any]]:
"""Generic filter: вернуть features у которых layer начинается с layer_prefix.
Предназначен для re-use в PR 2+ (opportunity layers, etc.).
Args:
features: list of feature dicts из features_json (уже загруженных в Python).
layer_prefix: префикс для фильтрации, e.g. 'risk_', 'zouit_'.
Returns:
Подсписок features где feat['layer'].startswith(layer_prefix).
"""
return [
f
for f in features
if isinstance(f.get("layer"), str) and f["layer"].startswith(layer_prefix)
]
def _get_risk_zones(
db: Session,
quarter: str,
parcel_wkt: str,
layer_counts: dict[str, int] | None = None,
) -> list[dict[str, Any]]:
"""TIER 3: риск-зоны НСПД (11 слоёв), пересекающие участок.
Возвращает список риск-зон с полями:
layer, subtype, geom_wkt, intersection_area_sqm.
layer_counts денормализованные счётчики. Если risks_count == 0
пропускаем heavy jsonb_array_elements scan.
"""
if layer_counts is not None and layer_counts.get("risks_count", 1) == 0:
return []
try:
rows = db.execute(
text(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Area(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
)
) AS intersection_area_sqm
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'risk_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
),
ST_GeomFromText(:wkt, 4326)
)
ORDER BY layer, intersection_area_sqm DESC NULLS LAST
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
layer: str = r[0] or ""
props: dict[str, Any] = r[1] if isinstance(r[1], dict) else {}
geom_wkt_val: str | None = r[2]
raw_area: Any = r[3]
intersection_area_sqm: float | None = None
if raw_area is not None:
try:
val = float(raw_area)
intersection_area_sqm = round(val, 1) if not math.isnan(val) else None
except (TypeError, ValueError):
pass
# Извлечь short_name из "risk_<short_name>" для человекочитаемого subtype
short_name = layer.removeprefix("risk_")
# Попытка взять subtype из properties, затем из mapping
subtype = (
props.get("type_zone")
or props.get("subcategory")
or props.get("name")
or _RISK_SUBTYPE_LABELS.get(short_name)
)
if subtype is not None:
subtype = str(subtype)
result.append(
{
"layer": layer,
"subtype": subtype,
"geom_wkt": geom_wkt_val,
"intersection_area_sqm": intersection_area_sqm,
}
)
return result
except Exception as e:
logger.warning("nspd risk zones query failed for quarter=%s: %s", quarter, e)
return []
# ── Connection-points lookup (issue #115) ────────────────────────────────────

View file

@ -389,7 +389,10 @@ def harvest_stale_quarters(
enqueued = 0
for cad in stale_cads:
try:
harvest_quarter.apply_async(args=[cad, region_code])
harvest_quarter.apply_async(
args=[cad, region_code],
kwargs={"include_zouit": True, "include_risks": True},
)
enqueued += 1
except Exception as e:
logger.warning("harvest_stale_quarters: enqueue failed for cad=%s: %s", cad, e)

View file

@ -0,0 +1,200 @@
"""Тесты для quarter_dump_lookup.py — risk zones + generic layer extraction.
Покрывает:
- _extract_features_by_layer: filter by prefix
- _get_risk_zones / extract through get_quarter_dump_data: intersect, no-intersect,
no-risks-in-dump
Не требует реального PostgreSQL мокает db.execute().
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import pytest
from app.services.site_finder.quarter_dump_lookup import (
_extract_features_by_layer,
_get_risk_zones,
derive_quarter_cad,
make_empty_result,
)
# ── Fixtures & helpers ────────────────────────────────────────────────────────
def _make_feature(layer: str, geom: dict[str, Any] | None = None) -> dict[str, Any]:
"""Минимальный feature dict в формате features_json."""
return {
"layer": layer,
"feature_id": "test-id",
"geometry": geom or {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]},
"properties": {"name": f"test-{layer}", "type_zone": f"zone-{layer}"},
}
# ── _extract_features_by_layer ────────────────────────────────────────────────
def test_extract_features_by_layer_prefix_match() -> None:
"""Возвращает только features с нужным prefix."""
features = [
_make_feature("risk_flooding"),
_make_feature("risk_burns"),
_make_feature("zouit_engineering"),
_make_feature("parcels"),
_make_feature("risk_landslide"),
]
result = _extract_features_by_layer(features, "risk_")
assert len(result) == 3
layers = {f["layer"] for f in result}
assert layers == {"risk_flooding", "risk_burns", "risk_landslide"}
def test_extract_features_by_layer_zouit_prefix() -> None:
"""Работает для zouit_ prefix — generic re-use."""
features = [
_make_feature("zouit_engineering"),
_make_feature("zouit_okn"),
_make_feature("risk_flooding"),
]
result = _extract_features_by_layer(features, "zouit_")
assert len(result) == 2
assert all(f["layer"].startswith("zouit_") for f in result)
def test_extract_features_by_layer_empty_list() -> None:
result = _extract_features_by_layer([], "risk_")
assert result == []
def test_extract_features_by_layer_no_match() -> None:
features = [_make_feature("parcels"), _make_feature("buildings")]
result = _extract_features_by_layer(features, "risk_")
assert result == []
def test_extract_features_by_layer_non_string_layer() -> None:
"""Gracefully skips features with non-string layer."""
features: list[dict[str, Any]] = [
{"layer": None, "feature_id": "x", "geometry": None, "properties": {}},
_make_feature("risk_flooding"),
]
result = _extract_features_by_layer(features, "risk_")
assert len(result) == 1
assert result[0]["layer"] == "risk_flooding"
# ── _get_risk_zones ───────────────────────────────────────────────────────────
def _make_mock_db_with_rows(rows: list[tuple[Any, ...]]) -> MagicMock:
"""Мок Session где execute().fetchall() возвращает rows."""
db = MagicMock()
mock_result = MagicMock()
mock_result.fetchall.return_value = rows
db.execute.return_value = mock_result
return db
def test_get_risk_zones_intersects() -> None:
"""Три risk features → три записи в результате."""
rows: list[tuple[Any, ...]] = [
# (layer, props, geom_wkt, intersection_area_sqm)
(
"risk_flooding",
{"type_zone": "Затопление 1%"},
"POLYGON((0 0,1 0,1 1,0 0))",
1234.5,
),
(
"risk_burns",
{"name": "Гарь"},
"POLYGON((0 0,2 0,2 2,0 0))",
500.0,
),
(
"risk_landslide",
{},
"POLYGON((0 0,3 0,3 3,0 0))",
999.9,
),
]
db = _make_mock_db_with_rows(rows)
result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
assert len(result) == 3
flooding = next(r for r in result if r["layer"] == "risk_flooding")
assert flooding["subtype"] == "Затопление 1%"
assert flooding["intersection_area_sqm"] == 1234.5
assert flooding["geom_wkt"] == "POLYGON((0 0,1 0,1 1,0 0))"
burns = next(r for r in result if r["layer"] == "risk_burns")
assert burns["subtype"] == "Гарь"
landslide = next(r for r in result if r["layer"] == "risk_landslide")
# No properties name/type_zone → falls back to _RISK_SUBTYPE_LABELS
assert landslide["subtype"] == "Обвально-осыпные процессы"
def test_get_risk_zones_no_intersect() -> None:
"""Пустой fetchall → пустой список."""
db = _make_mock_db_with_rows([])
result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
assert result == []
def test_get_risk_zones_no_risks_in_dump() -> None:
"""risks_count == 0 → early exit, db.execute не вызывается."""
db = MagicMock()
layer_counts = {"risks_count": 0}
result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", layer_counts)
assert result == []
db.execute.assert_not_called()
def test_get_risk_zones_db_exception_returns_empty() -> None:
"""DB exception → пустой список (не propagate)."""
db = MagicMock()
db.execute.side_effect = Exception("DB connection lost")
result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
assert result == []
def test_get_risk_zones_null_area() -> None:
"""NULL intersection_area_sqm → intersection_area_sqm=None в результате."""
rows = [("risk_erosion_water", {}, "POINT(0 0)", None)]
db = _make_mock_db_with_rows(rows)
result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
assert len(result) == 1
assert result[0]["intersection_area_sqm"] is None
# ── make_empty_result includes nspd_risk_zones ────────────────────────────────
def test_make_empty_result_has_risk_zones_key() -> None:
"""make_empty_result возвращает nspd_risk_zones: []."""
result = make_empty_result()
assert "nspd_risk_zones" in result
assert result["nspd_risk_zones"] == []
# ── derive_quarter_cad (smoke tests) ──────────────────────────────────────────
@pytest.mark.parametrize(
"cad,expected",
[
("66:41:0204016", "66:41:0204016"),
("66:41:0204016:10", "66:41:0204016"),
("66:41:0204016:10:1", "66:41:0204016"),
("invalid", None),
("66:41", None),
],
)
def test_derive_quarter_cad(cad: str, expected: str | None) -> None:
assert derive_quarter_cad(cad) == expected

View file

@ -10,6 +10,7 @@ import { NeighborsBlock } from "./NeighborsBlock";
import { NspdZoningBlock } from "./NspdZoningBlock";
import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock";
import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock";
import { NspdRiskZonesBlock } from "./NspdRiskZonesBlock";
import { NspdFreshnessBadge } from "./NspdFreshnessBadge";
interface Props {
@ -24,7 +25,8 @@ export function LandTab({ data }: Props) {
data.neighbors_summary !== undefined ||
data.nspd_zoning !== undefined ||
data.nspd_zouit_overlaps !== undefined ||
data.nspd_engineering_nearby !== undefined;
data.nspd_engineering_nearby !== undefined ||
data.nspd_risk_zones !== undefined;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
@ -68,6 +70,16 @@ export function LandTab({ data }: Props) {
</div>
)}
{/* Issue #94 TIER 3 — Риск-зоны НСПД */}
{data.nspd_risk_zones !== undefined && (
<div>
<SectionLabel style={{ marginBottom: 10 }}>
Природные риски (НСПД)
</SectionLabel>
<NspdRiskZonesBlock riskZones={data.nspd_risk_zones} />
</div>
)}
{/* Issue #202 — Инженерные объекты в 200 м */}
{data.nspd_engineering_nearby !== undefined && (
<div>

View file

@ -0,0 +1,147 @@
"use client";
import type { RiskZone } from "@/types/nspd";
interface Props {
riskZones: RiskZone[] | null | undefined;
parcelAreaSqm?: number | null;
}
// Severity mapping: layer key suffix → severity tier
function getSeverity(layer: string): "high" | "medium" | "low" {
const key = layer.replace(/^risk_/, "");
if (key === "flooding" || key === "landslide") return "high";
if (key === "flooding_underground" || key === "burns") return "medium";
return "low";
}
const SEVERITY_STYLES: Record<
"high" | "medium" | "low",
{ bg: string; badgeBg: string; color: string; label: string }
> = {
high: {
bg: "#fff1f2",
badgeBg: "#fee2e2",
color: "#991b1b",
label: "ВЫСОКИЙ",
},
medium: {
bg: "#fffbeb",
badgeBg: "#fef3c7",
color: "#92400e",
label: "СРЕДНИЙ",
},
low: {
bg: "#fff7ed",
badgeBg: "#ffedd5",
color: "#9a3412",
label: "НИЗКИЙ",
},
};
function formatArea(sqm: number | null): string | null {
if (sqm === null) return null;
if (sqm >= 10000) return `${(sqm / 10000).toFixed(2)} га`;
return `${Math.round(sqm).toLocaleString("ru-RU")} м²`;
}
export function NspdRiskZonesBlock({ riskZones, parcelAreaSqm }: Props) {
const zones = riskZones ?? [];
if (zones.length === 0) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "#f0fdf4",
border: "1px solid #bbf7d0",
borderRadius: 8,
padding: "10px 14px",
}}
>
<span
style={{
background: "#dcfce7",
color: "#15803d",
borderRadius: 6,
padding: "2px 10px",
fontSize: 12,
fontWeight: 600,
}}
>
Риски не обнаружены
</span>
<span style={{ fontSize: 13, color: "#15803d" }}>
Риск-зоны НСПД на участке не выявлены
</span>
</div>
);
}
// Sort: high first, then medium, then low
const sorted = [...zones].sort((a, b) => {
const order = { high: 0, medium: 1, low: 2 };
return order[getSeverity(a.layer)] - order[getSeverity(b.layer)];
});
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{sorted.map((zone, idx) => {
const severity = getSeverity(zone.layer);
const style = SEVERITY_STYLES[severity];
const areaStr = formatArea(zone.intersection_area_sqm);
const pct =
parcelAreaSqm && zone.intersection_area_sqm
? Math.min(
100,
Math.round((zone.intersection_area_sqm / parcelAreaSqm) * 100),
)
: null;
return (
<div
key={idx}
style={{
background: style.bg,
border: `1px solid ${style.badgeBg}`,
borderRadius: 8,
padding: "10px 14px",
display: "flex",
alignItems: "flex-start",
gap: 10,
}}
>
<span
style={{
background: style.badgeBg,
color: style.color,
borderRadius: 5,
padding: "2px 8px",
fontSize: 11,
fontWeight: 700,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{style.label}
</span>
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: "#1f2937" }}>
{zone.subtype ?? zone.layer}
</div>
{(areaStr || pct !== null) && (
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>
{areaStr && <span>Площадь пересечения: {areaStr}</span>}
{areaStr && pct !== null && <span> · </span>}
{pct !== null && <span>{pct}% участка</span>}
</div>
)}
</div>
</div>
);
})}
</div>
);
}

View file

@ -32,6 +32,15 @@ export interface NspdDumpMeta {
total_features: number | null;
}
// Risk zones (issue #94 TIER 3) — from /analyze nspd_risk_zones field
export interface RiskZone {
layer: string; // e.g. "risk_flooding", "risk_landslide"
subtype: string | null; // human-readable label from NSPD properties or mapping
geom_wkt: string | null;
intersection_area_sqm: number | null;
}
// Connection-points endpoint shapes (/api/v1/parcels/{cad}/connection-points)
export interface EngineeringStructure {

View file

@ -383,6 +383,7 @@ export interface ParcelAnalysis {
nspd_zoning?: import("./nspd").NspdZoning | null;
nspd_zouit_overlaps?: import("./nspd").NspdZouitOverlap[];
nspd_engineering_nearby?: import("./nspd").NspdEngineeringNearby[];
nspd_risk_zones?: import("./nspd").RiskZone[];
nspd_dump?: import("./nspd").NspdDumpMeta | null;
}