feat(site-finder): P1 geometry suitability score (#45)
Backend (parcels.py): - _polygon_suitability() — Shapely metrics on parcel WGS84 polygon: area_ha/m2 (projected via cos(lat) к UTM-like meters), perimeter_m, aspect_ratio (long/short side of MABR), convex_hull_ratio (area / hull area), min_inscribed_rect_dim_m. - Composite suitability score 0..1: base = area_subscore (0 при <0.2 ha, linear → 1 при ≥0.5 ha) −0.3 если aspect_ratio > 5 (вытянутый) −0.3 если convex_hull_ratio < 0.65 (изрезанный) −0.5 если min_inscribed_rect_dim_m < 30 (узкий) - Label: микро / подходящий / сложная форма / слабо подходит - Recommendation: "для МКД 16+ нужно >0.3 ha + минимум 40м" - В analyze response → geometry_suitability. Frontend: - Новый GeometrySuitabilityBlock.tsx с color-coded badge + метрики grid + penalties + recommendation - Добавлен в LandTab (выше "Зонирование (ПЗЗ)") - TS типы расширены: GeometrySuitability Closes #45.
This commit is contained in:
parent
a35a3d02ea
commit
f4e74916be
4 changed files with 350 additions and 2 deletions
|
|
@ -140,7 +140,7 @@ def _fetch_seasonal_weather_sync(lat: float, lon: float) -> dict | None:
|
||||||
"period": "1995-2024 (30 лет)",
|
"period": "1995-2024 (30 лет)",
|
||||||
"model": "MRI-AGCM3-2-S",
|
"model": "MRI-AGCM3-2-S",
|
||||||
"source": "open-meteo-climate",
|
"source": "open-meteo-climate",
|
||||||
"note": ("Климатические нормали. " "Текущая погода — отдельный API."),
|
"note": ("Климатические нормали. Текущая погода — отдельный API."),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("seasonal weather fetch failed: %s", e)
|
logger.warning("seasonal weather fetch failed: %s", e)
|
||||||
|
|
@ -274,6 +274,139 @@ GEOTECH_BY_REGION: dict[int, dict[str, Any]] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _polygon_suitability(geom_wkt: str) -> dict[str, Any]:
|
||||||
|
"""P1 (#45) — physical suitability участка по метрикам shape.
|
||||||
|
|
||||||
|
Метрики:
|
||||||
|
- area_ha — площадь в гектарах (через UTM zone 41N для ЕКБ — точнее WGS84 deg²)
|
||||||
|
- perimeter_m — периметр
|
||||||
|
- aspect_ratio — длина / ширина минимального ограничивающего прямоугольника
|
||||||
|
- convex_hull_ratio — площадь / площадь выпуклой оболочки (1.0 = выпуклый, <0.7 изрезанный)
|
||||||
|
- min_inscribed_rect_dim_m — длина короткой стороны MABR
|
||||||
|
|
||||||
|
Suitability score 0..1 — composite:
|
||||||
|
- base 1.0
|
||||||
|
- площадь: <0.2 ha → 0.0, ≥0.5 ha → 1.0, linear в промежутке
|
||||||
|
- −0.3 если aspect_ratio > 5 (вытянутый)
|
||||||
|
- −0.3 если convex_hull_ratio < 0.65 (изрезанный)
|
||||||
|
- −0.5 если min_inscribed_rect_dim_m < 30 (узкий)
|
||||||
|
|
||||||
|
UI label: подходящий / сложная форма / слишком узкий / микро.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from shapely import wkt as _wkt
|
||||||
|
from shapely.geometry import Polygon
|
||||||
|
|
||||||
|
# Парсим WGS84 polygon
|
||||||
|
poly = _wkt.loads(geom_wkt)
|
||||||
|
if poly.is_empty or poly.geom_type not in ("Polygon", "MultiPolygon"):
|
||||||
|
return {"data_available": False, "note": "Геометрия не Polygon/MultiPolygon"}
|
||||||
|
|
||||||
|
# Берём наибольший компонент для MultiPolygon
|
||||||
|
if poly.geom_type == "MultiPolygon":
|
||||||
|
poly = max(poly.geoms, key=lambda g: g.area)
|
||||||
|
assert isinstance(poly, Polygon)
|
||||||
|
|
||||||
|
# Точная площадь — проектируем в UTM zone 41N (ЕКБ 60°E), EPSG:32641
|
||||||
|
# Простая оценка: на широте ~57° 1° долготы ≈ 60.5 км, 1° широты ≈ 111 км.
|
||||||
|
# Для парcel-level scale достаточно — деформация <1% в радиусе 50км.
|
||||||
|
centroid = poly.centroid
|
||||||
|
lat_rad = math.radians(centroid.y)
|
||||||
|
m_per_deg_lon = 111_320.0 * math.cos(lat_rad)
|
||||||
|
m_per_deg_lat = 110_540.0
|
||||||
|
# Преобразуем в метры: умножаем X на m_per_deg_lon, Y на m_per_deg_lat.
|
||||||
|
# Shapely не имеет встроенной reproj; работаем через scaling координат.
|
||||||
|
ext = list(poly.exterior.coords)
|
||||||
|
ext_m = [
|
||||||
|
(
|
||||||
|
(x - centroid.x) * m_per_deg_lon,
|
||||||
|
(y - centroid.y) * m_per_deg_lat,
|
||||||
|
)
|
||||||
|
for x, y in ext
|
||||||
|
]
|
||||||
|
poly_m = Polygon(ext_m)
|
||||||
|
area_m2 = poly_m.area
|
||||||
|
area_ha = area_m2 / 10_000.0
|
||||||
|
perimeter_m = poly_m.length
|
||||||
|
|
||||||
|
# Convex hull ratio
|
||||||
|
hull = poly_m.convex_hull
|
||||||
|
convex_hull_ratio = area_m2 / hull.area if hull.area > 0 else 1.0
|
||||||
|
|
||||||
|
# MABR (minimum area bounding rectangle) → aspect_ratio + short side
|
||||||
|
try:
|
||||||
|
mabr = poly_m.minimum_rotated_rectangle
|
||||||
|
mabr_coords = list(mabr.exterior.coords)
|
||||||
|
# 4 уникальные точки в MABR → две стороны
|
||||||
|
side_lens: list[float] = []
|
||||||
|
for i in range(4):
|
||||||
|
p1 = mabr_coords[i]
|
||||||
|
p2 = mabr_coords[i + 1]
|
||||||
|
side_lens.append(math.hypot(p2[0] - p1[0], p2[1] - p1[1]))
|
||||||
|
short_side = min(side_lens)
|
||||||
|
long_side = max(side_lens)
|
||||||
|
aspect_ratio = long_side / short_side if short_side > 0 else 1.0
|
||||||
|
except Exception:
|
||||||
|
short_side = math.sqrt(area_m2)
|
||||||
|
aspect_ratio = 1.0
|
||||||
|
|
||||||
|
# Suitability score composite
|
||||||
|
if area_ha >= 0.5:
|
||||||
|
area_subscore = 1.0
|
||||||
|
elif area_ha <= 0.2:
|
||||||
|
area_subscore = 0.0
|
||||||
|
else:
|
||||||
|
# linear 0.2 ha → 0, 0.5 ha → 1.0
|
||||||
|
area_subscore = (area_ha - 0.2) / 0.3
|
||||||
|
|
||||||
|
suitability = area_subscore
|
||||||
|
penalties: list[str] = []
|
||||||
|
if aspect_ratio > 5:
|
||||||
|
suitability -= 0.3
|
||||||
|
penalties.append("вытянутый (aspect>5)")
|
||||||
|
if convex_hull_ratio < 0.65:
|
||||||
|
suitability -= 0.3
|
||||||
|
penalties.append("изрезанный (convex<0.65)")
|
||||||
|
if short_side < 30:
|
||||||
|
suitability -= 0.5
|
||||||
|
penalties.append(f"узкий (короткая сторона {short_side:.0f}м)")
|
||||||
|
suitability = max(0.0, min(1.0, suitability))
|
||||||
|
|
||||||
|
# Label
|
||||||
|
if area_ha < 0.05:
|
||||||
|
label = "микро"
|
||||||
|
elif suitability >= 0.7:
|
||||||
|
label = "подходящий"
|
||||||
|
elif suitability >= 0.4:
|
||||||
|
label = "сложная форма"
|
||||||
|
else:
|
||||||
|
label = "слабо подходит"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data_available": True,
|
||||||
|
"area_ha": round(area_ha, 3),
|
||||||
|
"area_m2": round(area_m2),
|
||||||
|
"perimeter_m": round(perimeter_m),
|
||||||
|
"aspect_ratio": round(aspect_ratio, 2),
|
||||||
|
"convex_hull_ratio": round(convex_hull_ratio, 2),
|
||||||
|
"min_inscribed_rect_dim_m": round(short_side),
|
||||||
|
"suitability_score": round(suitability, 2),
|
||||||
|
"label": label,
|
||||||
|
"penalties": penalties,
|
||||||
|
"recommendation": ("Для МКД 16+ этажей нужно >0.3 ha + минимальная ширина 40м."),
|
||||||
|
"note": (
|
||||||
|
"Оценка по форме участка (Shapely). Учитывает площадь, "
|
||||||
|
"вытянутость, изрезанность, минимальную ширину MABR."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("polygon suitability failed: %s", e)
|
||||||
|
return {
|
||||||
|
"data_available": False,
|
||||||
|
"note": f"Не удалось проанализировать геометрию: {e}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any]:
|
def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any]:
|
||||||
"""Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость.
|
"""Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость.
|
||||||
|
|
||||||
|
|
@ -942,6 +1075,8 @@ def analyze_parcel(
|
||||||
"hydrology": hydrology,
|
"hydrology": hydrology,
|
||||||
"utilities": utilities,
|
"utilities": utilities,
|
||||||
"geotech_risk": _geotech_risk(66, db, geom_wkt),
|
"geotech_risk": _geotech_risk(66, db, geom_wkt),
|
||||||
|
# P1 (#45) — physical suitability участка
|
||||||
|
"geometry_suitability": _polygon_suitability(geom_wkt),
|
||||||
"market_trend": market_trend,
|
"market_trend": market_trend,
|
||||||
"zoning": zoning,
|
"zoning": zoning,
|
||||||
"success_recommendation": success_recommendation,
|
"success_recommendation": success_recommendation,
|
||||||
|
|
|
||||||
186
frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx
Normal file
186
frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { GeometrySuitability } from "@/types/site-finder";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: GeometrySuitability;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL_COLOR: Record<
|
||||||
|
NonNullable<GeometrySuitability["label"]>,
|
||||||
|
{ bg: string; fg: string; border: string }
|
||||||
|
> = {
|
||||||
|
подходящий: { bg: "#dcfce7", fg: "#15803d", border: "#86efac" },
|
||||||
|
"сложная форма": { bg: "#fef9c3", fg: "#a16207", border: "#fde68a" },
|
||||||
|
"слабо подходит": { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5" },
|
||||||
|
микро: { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtArea(ha: number | undefined, m2: number | undefined): string {
|
||||||
|
if (ha !== undefined && ha >= 0.1) {
|
||||||
|
return `${ha.toFixed(2)} га`;
|
||||||
|
}
|
||||||
|
if (m2 !== undefined) {
|
||||||
|
return `${m2.toLocaleString("ru-RU")} м²`;
|
||||||
|
}
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeometrySuitabilityBlock({ data }: Props) {
|
||||||
|
if (!data.data_available) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "12px 16px",
|
||||||
|
background: "#f9fafb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Геометрия участка
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#9ca3af" }}>{data.note}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = data.label ? LABEL_COLOR[data.label] : LABEL_COLOR["сложная форма"];
|
||||||
|
const score = data.suitability_score ?? 0;
|
||||||
|
const scorePct = Math.round(score * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${c.border}`,
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 18px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Геометрия участка
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "2px 10px",
|
||||||
|
borderRadius: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: c.fg,
|
||||||
|
background: c.bg,
|
||||||
|
border: `1px solid ${c.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.label} · {scorePct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Метрики */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))",
|
||||||
|
gap: 10,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#374151",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#9ca3af", fontSize: 11 }}>Площадь</div>
|
||||||
|
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{fmtArea(data.area_ha, data.area_m2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#9ca3af", fontSize: 11 }}>Периметр</div>
|
||||||
|
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{data.perimeter_m ?? "—"} м
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#9ca3af", fontSize: 11 }}>
|
||||||
|
Соотношение сторон
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{data.aspect_ratio?.toFixed(2) ?? "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{ color: "#9ca3af", fontSize: 11 }}
|
||||||
|
title="Площадь / площадь выпуклой оболочки; 1.0 = выпуклый, <0.7 — изрезанный"
|
||||||
|
>
|
||||||
|
Выпуклость
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{data.convex_hull_ratio !== undefined
|
||||||
|
? `${(data.convex_hull_ratio * 100).toFixed(0)}%`
|
||||||
|
: "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{ color: "#9ca3af", fontSize: 11 }}
|
||||||
|
title="Длина короткой стороны минимального ограничивающего прямоугольника"
|
||||||
|
>
|
||||||
|
Мин. ширина
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{data.min_inscribed_rect_dim_m ?? "—"} м
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Penalties */}
|
||||||
|
{data.penalties && data.penalties.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: c.fg,
|
||||||
|
background: c.bg,
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚠ Проблемы формы: {data.penalties.join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recommendation */}
|
||||||
|
{data.recommendation && (
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280", fontStyle: "italic" }}>
|
||||||
|
{data.recommendation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
import { GeologyBlock } from "./GeologyBlock";
|
import { GeologyBlock } from "./GeologyBlock";
|
||||||
|
import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock";
|
||||||
import { GeotechRiskBlock } from "./GeotechRiskBlock";
|
import { GeotechRiskBlock } from "./GeotechRiskBlock";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -9,10 +10,18 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LandTab({ data }: Props) {
|
export function LandTab({ data }: Props) {
|
||||||
const hasAny = data.geotech_risk !== undefined || data.geology !== undefined;
|
const hasAny =
|
||||||
|
data.geotech_risk !== undefined ||
|
||||||
|
data.geology !== undefined ||
|
||||||
|
data.geometry_suitability !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||||
|
{/* P1 (#45) — Geometry suitability */}
|
||||||
|
{data.geometry_suitability !== undefined && (
|
||||||
|
<GeometrySuitabilityBlock data={data.geometry_suitability} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Zoning note */}
|
{/* Zoning note */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,22 @@ export interface ParcelLocation {
|
||||||
note: string;
|
note: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P1 (#45) — physical suitability участка
|
||||||
|
export interface GeometrySuitability {
|
||||||
|
data_available: boolean;
|
||||||
|
area_ha?: number;
|
||||||
|
area_m2?: number;
|
||||||
|
perimeter_m?: number;
|
||||||
|
aspect_ratio?: number;
|
||||||
|
convex_hull_ratio?: number;
|
||||||
|
min_inscribed_rect_dim_m?: number;
|
||||||
|
suitability_score?: number;
|
||||||
|
label?: "микро" | "подходящий" | "сложная форма" | "слабо подходит";
|
||||||
|
penalties?: string[];
|
||||||
|
recommendation?: string;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SuccessRankingBucket {
|
export interface SuccessRankingBucket {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
success_score: number;
|
success_score: number;
|
||||||
|
|
@ -184,6 +200,8 @@ export interface ParcelAnalysis {
|
||||||
score_without_center?: number;
|
score_without_center?: number;
|
||||||
location?: ParcelLocation;
|
location?: ParcelLocation;
|
||||||
success_recommendation?: ParcelSuccessRecommendation | null;
|
success_recommendation?: ParcelSuccessRecommendation | null;
|
||||||
|
// P1 (#45) — physical suitability участка
|
||||||
|
geometry_suitability?: GeometrySuitability;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PoiCategory =
|
export type PoiCategory =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue