feat(site-finder): P1 geometry suitability score (#45) #89
4 changed files with 393 additions and 1 deletions
|
|
@ -5,6 +5,8 @@ from typing import Annotated, Any
|
|||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from shapely import wkt as _shp_wkt
|
||||
from shapely.geometry import Polygon
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -289,6 +291,165 @@ GEOTECH_BY_REGION: dict[int, dict[str, Any]] = {
|
|||
}
|
||||
|
||||
|
||||
# P1 (#45) — constants for polygon suitability (строительные нормы Свердл/общие
|
||||
# для ЖК; будут править — храним в одном месте)
|
||||
_GEOM_MIN_AREA_HA = 0.2 # ниже → area_subscore = 0 (физический минимум)
|
||||
_GEOM_AREA_COMFORT_HA = 0.3 # рекомендуемая комфортная площадь МКД (recommendation)
|
||||
_GEOM_AREA_SCORE_FULL_HA = 0.5 # ≥ → area_subscore = 1.0 (premium)
|
||||
_GEOM_ASPECT_PENALTY_THRESHOLD = 5.0 # выше → вытянутый
|
||||
_GEOM_ASPECT_PENALTY = 0.3
|
||||
_GEOM_CONVEX_PENALTY_THRESHOLD = 0.65 # ниже → изрезанный
|
||||
_GEOM_CONVEX_PENALTY = 0.3
|
||||
# Строительный минимум — physical possibility (под penalty)
|
||||
_GEOM_MIN_WIDTH_PHYSICAL_M = 30
|
||||
_GEOM_NARROW_PENALTY = 0.5
|
||||
# Комфорт МКД — recommendation level (помещается типовой корпус 12-16 эт)
|
||||
_GEOM_MIN_WIDTH_COMFORT_M = 40
|
||||
_GEOM_LABEL_MICRO_HA = 0.05 # ниже → label "микро" (комбинируется с penalties)
|
||||
_GEOM_LABEL_GOOD = 0.7
|
||||
_GEOM_LABEL_MEDIUM = 0.4
|
||||
|
||||
|
||||
def _polygon_suitability(geom_wkt: str) -> dict[str, Any]:
|
||||
"""P1 (#45) — physical suitability участка по метрикам shape.
|
||||
|
||||
Метрики:
|
||||
- area_ha — площадь в гектарах (locally-projected metres via cos(lat))
|
||||
- perimeter_m — периметр
|
||||
- aspect_ratio — длина / ширина минимального ограничивающего прямоугольника
|
||||
- convex_hull_ratio — площадь / площадь выпуклой оболочки (1.0 = выпуклый, <0.7 изрезанный)
|
||||
- min_inscribed_rect_dim_m — длина короткой стороны MABR
|
||||
|
||||
Suitability score 0..1 — composite (пороги — см. _GEOM_* константы):
|
||||
- area_subscore: <_GEOM_MIN_AREA_HA → 0.0, ≥_GEOM_AREA_SCORE_FULL_HA → 1.0, linear
|
||||
- −_GEOM_ASPECT_PENALTY если aspect_ratio > _GEOM_ASPECT_PENALTY_THRESHOLD
|
||||
- −_GEOM_CONVEX_PENALTY если convex_hull_ratio < _GEOM_CONVEX_PENALTY_THRESHOLD
|
||||
- −_GEOM_NARROW_PENALTY если short_side < _GEOM_MIN_WIDTH_PHYSICAL_M
|
||||
|
||||
UI label: микро / подходящий / сложная форма / слабо подходит. Label "микро"
|
||||
комбинируется с penalties — "микро · узкий" — чтобы пользователь увидел
|
||||
обе проблемы сразу.
|
||||
"""
|
||||
try:
|
||||
# Парсим WGS84 polygon (shapely imports теперь module-level)
|
||||
poly = _shp_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)
|
||||
|
||||
# Equirectangular-projection в метры через centroid-anchor.
|
||||
# На широте ~57° деформация <1% в радиусе 50км (parcel-scale OK).
|
||||
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
|
||||
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 (closed ring → 5 points) → две стороны
|
||||
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 as mabr_err:
|
||||
logger.debug("MABR computation failed, falling back to sqrt(area): %s", mabr_err)
|
||||
short_side = math.sqrt(area_m2)
|
||||
aspect_ratio = 1.0
|
||||
|
||||
# Suitability score composite
|
||||
if area_ha >= _GEOM_AREA_SCORE_FULL_HA:
|
||||
area_subscore = 1.0
|
||||
elif area_ha <= _GEOM_MIN_AREA_HA:
|
||||
area_subscore = 0.0
|
||||
else:
|
||||
# linear: _GEOM_MIN_AREA_HA → 0, _GEOM_AREA_SCORE_FULL_HA → 1.0
|
||||
area_subscore = (area_ha - _GEOM_MIN_AREA_HA) / (
|
||||
_GEOM_AREA_SCORE_FULL_HA - _GEOM_MIN_AREA_HA
|
||||
)
|
||||
|
||||
suitability = area_subscore
|
||||
penalties: list[str] = []
|
||||
if aspect_ratio > _GEOM_ASPECT_PENALTY_THRESHOLD:
|
||||
suitability -= _GEOM_ASPECT_PENALTY
|
||||
penalties.append(f"вытянутый (aspect>{_GEOM_ASPECT_PENALTY_THRESHOLD:.0f})")
|
||||
if convex_hull_ratio < _GEOM_CONVEX_PENALTY_THRESHOLD:
|
||||
suitability -= _GEOM_CONVEX_PENALTY
|
||||
penalties.append(f"изрезанный (convex<{_GEOM_CONVEX_PENALTY_THRESHOLD})")
|
||||
if short_side < _GEOM_MIN_WIDTH_PHYSICAL_M:
|
||||
suitability -= _GEOM_NARROW_PENALTY
|
||||
penalties.append(f"узкий (короткая сторона {short_side:.0f}м)")
|
||||
suitability = max(0.0, min(1.0, suitability))
|
||||
|
||||
# Label — combine "микро" с penalties чтобы UI видел всё
|
||||
is_micro = area_ha < _GEOM_LABEL_MICRO_HA
|
||||
if suitability >= _GEOM_LABEL_GOOD and not is_micro:
|
||||
label = "подходящий"
|
||||
elif is_micro:
|
||||
# combine с penalties: "микро" + первая penalty (для краткости)
|
||||
if penalties:
|
||||
label = f"микро, {penalties[0].split(' (')[0]}"
|
||||
else:
|
||||
label = "микро"
|
||||
elif suitability >= _GEOM_LABEL_MEDIUM:
|
||||
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": (
|
||||
f"Строительный минимум короткой стороны — {_GEOM_MIN_WIDTH_PHYSICAL_M}м, "
|
||||
f"комфорт типового МКД 12-16 этажей — от {_GEOM_MIN_WIDTH_COMFORT_M}м "
|
||||
f"и площадь от {_GEOM_AREA_COMFORT_HA} га."
|
||||
),
|
||||
"note": (
|
||||
"Оценка по форме участка (Shapely). Учитывает площадь, "
|
||||
"вытянутость, изрезанность, минимальную ширину MABR."
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("polygon suitability failed: %s", e)
|
||||
return {
|
||||
"data_available": False,
|
||||
"note": f"Не удалось проанализировать геометрию: {e}",
|
||||
}
|
||||
|
||||
|
||||
# P2 (#46) cost-per-m² sanity filter — кадастровая стоимость иногда
|
||||
# содержит 0/None или экстремальные значения (миллиарды). Пороги выбраны
|
||||
# эмпирически для ЕКБ.
|
||||
|
|
@ -1245,6 +1406,8 @@ def analyze_parcel(
|
|||
"hydrology": hydrology,
|
||||
"utilities": utilities,
|
||||
"geotech_risk": _geotech_risk(66, db, geom_wkt),
|
||||
# P1 (#45) — physical suitability участка
|
||||
"geometry_suitability": _polygon_suitability(geom_wkt),
|
||||
# P2 (#46) — соседи-здания + overlap check
|
||||
"neighbors_summary": _neighbors_summary(db, geom_wkt, cad_num),
|
||||
"market_trend": market_trend,
|
||||
|
|
|
|||
196
frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx
Normal file
196
frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"use client";
|
||||
|
||||
import type {
|
||||
GeometrySuitability,
|
||||
GeometrySuitabilityBaseLabel,
|
||||
} from "@/types/site-finder";
|
||||
|
||||
interface Props {
|
||||
data: GeometrySuitability;
|
||||
}
|
||||
|
||||
const LABEL_COLOR: Record<
|
||||
GeometrySuitabilityBaseLabel,
|
||||
{ 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" },
|
||||
};
|
||||
|
||||
// label может быть combo "микро, узкий" — берём первую часть как ключ для цвета.
|
||||
function colorForLabel(label: string | undefined) {
|
||||
if (!label) return LABEL_COLOR["сложная форма"];
|
||||
const base = label.split(",")[0].trim() as GeometrySuitabilityBaseLabel;
|
||||
return LABEL_COLOR[base] ?? LABEL_COLOR["сложная форма"];
|
||||
}
|
||||
|
||||
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 = colorForLabel(data.label);
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<strong>Проблемы формы:</strong> {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 { GeologyBlock } from "./GeologyBlock";
|
||||
import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock";
|
||||
import { GeotechRiskBlock } from "./GeotechRiskBlock";
|
||||
import { NeighborsBlock } from "./NeighborsBlock";
|
||||
|
||||
|
|
@ -13,15 +14,21 @@ export function LandTab({ data }: Props) {
|
|||
const hasAny =
|
||||
data.geotech_risk !== undefined ||
|
||||
data.geology !== undefined ||
|
||||
data.geometry_suitability !== undefined ||
|
||||
data.neighbors_summary !== undefined;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
{/* P2 (#46) — Соседи + overlap warning */}
|
||||
{/* P2 (#46) — Соседи + overlap warning (hard warn если overlap) */}
|
||||
{data.neighbors_summary && (
|
||||
<NeighborsBlock data={data.neighbors_summary} />
|
||||
)}
|
||||
|
||||
{/* P1 (#45) — Geometry suitability */}
|
||||
{data.geometry_suitability !== undefined && (
|
||||
<GeometrySuitabilityBlock data={data.geometry_suitability} />
|
||||
)}
|
||||
|
||||
{/* Zoning note */}
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -178,6 +178,30 @@ export interface ParcelLocation {
|
|||
note: string;
|
||||
}
|
||||
|
||||
// P1 (#45) — physical suitability участка.
|
||||
// label — base value один из BaseLabel'ов либо combo "микро, <first-penalty>"
|
||||
export type GeometrySuitabilityBaseLabel =
|
||||
| "микро"
|
||||
| "подходящий"
|
||||
| "сложная форма"
|
||||
| "слабо подходит";
|
||||
|
||||
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;
|
||||
// string — допускаем combo-label "микро, узкий"; см. GeometrySuitabilityBaseLabel
|
||||
label?: string;
|
||||
penalties?: string[];
|
||||
recommendation?: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface SuccessRankingBucket {
|
||||
bucket: string;
|
||||
success_score: number;
|
||||
|
|
@ -218,6 +242,8 @@ export interface ParcelAnalysis {
|
|||
score_without_center?: number;
|
||||
location?: ParcelLocation;
|
||||
success_recommendation?: ParcelSuccessRecommendation | null;
|
||||
// P1 (#45) — physical suitability участка
|
||||
geometry_suitability?: GeometrySuitability;
|
||||
// P2 (#46) — cad_buildings соседи + overlap check
|
||||
neighbors_summary?: NeighborsSummary;
|
||||
// X2 (#48) — confidence indicator
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue