feat(nspd): TIER 3 risk zones (#94 PR1 of 4) #217

Merged
lekss361 merged 1 commit from feat/94-pr1-risk-zones into main 2026-05-16 15:03:19 +00:00
9 changed files with 545 additions and 3 deletions

View file

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

View file

@ -68,6 +68,18 @@ class ConnectionPointsResponse(BaseModel):
dump_fetched_at: str | None 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 ────────────────────────────────────────────── # ── Parcel search/detail schemas ──────────────────────────────────────────────

View file

@ -15,6 +15,7 @@ from __future__ import annotations
import json import json
import logging import logging
import math
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
@ -37,6 +38,7 @@ EMPTY_DUMP_RESULT: dict[str, Any] = {
"nspd_zoning": None, "nspd_zoning": None,
"nspd_zouit_overlaps": [], "nspd_zouit_overlaps": [],
"nspd_engineering_nearby": [], "nspd_engineering_nearby": [],
"nspd_risk_zones": [],
"nspd_dump": { "nspd_dump": {
"available": False, "available": False,
"fetched_at_utc": None, "fetched_at_utc": None,
@ -62,6 +64,7 @@ def make_empty_result(
"nspd_zoning": None, "nspd_zoning": None,
"nspd_zouit_overlaps": [], "nspd_zouit_overlaps": [],
"nspd_engineering_nearby": [], "nspd_engineering_nearby": [],
"nspd_risk_zones": [],
"nspd_dump": { "nspd_dump": {
"available": False, "available": False,
"fetched_at_utc": fetched_at_utc, "fetched_at_utc": fetched_at_utc,
@ -124,7 +127,8 @@ def get_quarter_dump_data(
harvest_error, harvest_error,
territorial_zones_count, territorial_zones_count,
zouit_count, zouit_count,
engineering_count engineering_count,
risks_count
FROM nspd_quarter_dumps FROM nspd_quarter_dumps
WHERE quarter_cad = :q WHERE quarter_cad = :q
""" """
@ -150,6 +154,7 @@ def get_quarter_dump_data(
territorial_zones_count: int = row[4] or 0 territorial_zones_count: int = row[4] or 0
zouit_count: int = row[5] or 0 zouit_count: int = row[5] or 0
engineering_count: int = row[6] or 0 engineering_count: int = row[6] or 0
risks_count: int = row[7] or 0
is_stale = (now - fetched_at) > max_age is_stale = (now - fetched_at) > max_age
has_error = harvest_error is not None has_error = harvest_error is not None
@ -179,6 +184,7 @@ def get_quarter_dump_data(
"nspd_zoning": None, "nspd_zoning": None,
"nspd_zouit_overlaps": [], "nspd_zouit_overlaps": [],
"nspd_engineering_nearby": [], "nspd_engineering_nearby": [],
"nspd_risk_zones": [],
"nspd_dump": dump_meta, "nspd_dump": dump_meta,
} }
@ -186,15 +192,18 @@ def get_quarter_dump_data(
"territorial_zones_count": territorial_zones_count, "territorial_zones_count": territorial_zones_count,
"zouit_count": zouit_count, "zouit_count": zouit_count,
"engineering_count": engineering_count, "engineering_count": engineering_count,
"risks_count": risks_count,
} }
nspd_zoning = _get_zoning(db, quarter, parcel_wkt, layer_counts) nspd_zoning = _get_zoning(db, quarter, parcel_wkt, layer_counts)
nspd_zouit = _get_zouit_overlaps(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_engineering = _get_engineering_nearby(db, quarter, parcel_wkt, layer_counts)
nspd_risk_zones = _get_risk_zones(db, quarter, parcel_wkt, layer_counts)
return { return {
"nspd_zoning": nspd_zoning, "nspd_zoning": nspd_zoning,
"nspd_zouit_overlaps": nspd_zouit, "nspd_zouit_overlaps": nspd_zouit,
"nspd_engineering_nearby": nspd_engineering, "nspd_engineering_nearby": nspd_engineering,
"nspd_risk_zones": nspd_risk_zones,
"nspd_dump": dump_meta, "nspd_dump": dump_meta,
} }
@ -394,6 +403,152 @@ def _get_engineering_nearby(
return [] 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) ──────────────────────────────────── # ── Connection-points lookup (issue #115) ────────────────────────────────────

View file

@ -389,7 +389,10 @@ def harvest_stale_quarters(
enqueued = 0 enqueued = 0
for cad in stale_cads: for cad in stale_cads:
try: 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 enqueued += 1
except Exception as e: except Exception as e:
logger.warning("harvest_stale_quarters: enqueue failed for cad=%s: %s", cad, 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 { NspdZoningBlock } from "./NspdZoningBlock";
import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock"; import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock";
import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock"; import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock";
import { NspdRiskZonesBlock } from "./NspdRiskZonesBlock";
import { NspdFreshnessBadge } from "./NspdFreshnessBadge"; import { NspdFreshnessBadge } from "./NspdFreshnessBadge";
interface Props { interface Props {
@ -24,7 +25,8 @@ export function LandTab({ data }: Props) {
data.neighbors_summary !== undefined || data.neighbors_summary !== undefined ||
data.nspd_zoning !== undefined || data.nspd_zoning !== undefined ||
data.nspd_zouit_overlaps !== undefined || data.nspd_zouit_overlaps !== undefined ||
data.nspd_engineering_nearby !== undefined; data.nspd_engineering_nearby !== undefined ||
data.nspd_risk_zones !== undefined;
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}> <div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
@ -68,6 +70,16 @@ export function LandTab({ data }: Props) {
</div> </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 м */} {/* Issue #202 — Инженерные объекты в 200 м */}
{data.nspd_engineering_nearby !== undefined && ( {data.nspd_engineering_nearby !== undefined && (
<div> <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; 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) // Connection-points endpoint shapes (/api/v1/parcels/{cad}/connection-points)
export interface EngineeringStructure { export interface EngineeringStructure {

View file

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