From f4e74916be00adf0a97308011a3e6afef8312dbb Mon Sep 17 00:00:00 2001 From: lekss361 Date: Tue, 12 May 2026 00:51:08 +0300 Subject: [PATCH 1/3] feat(site-finder): P1 geometry suitability score (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/api/v1/parcels.py | 137 ++++++++++++- .../site-finder/GeometrySuitabilityBlock.tsx | 186 ++++++++++++++++++ .../src/components/site-finder/LandTab.tsx | 11 +- frontend/src/types/site-finder.ts | 18 ++ 4 files changed, 350 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 173e1af8..c20d433a 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -140,7 +140,7 @@ def _fetch_seasonal_weather_sync(lat: float, lon: float) -> dict | None: "period": "1995-2024 (30 лет)", "model": "MRI-AGCM3-2-S", "source": "open-meteo-climate", - "note": ("Климатические нормали. " "Текущая погода — отдельный API."), + "note": ("Климатические нормали. Текущая погода — отдельный API."), } except Exception as 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]: """Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость. @@ -942,6 +1075,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), "market_trend": market_trend, "zoning": zoning, "success_recommendation": success_recommendation, diff --git a/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx b/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx new file mode 100644 index 00000000..69b161ec --- /dev/null +++ b/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx @@ -0,0 +1,186 @@ +"use client"; + +import type { GeometrySuitability } from "@/types/site-finder"; + +interface Props { + data: GeometrySuitability; +} + +const LABEL_COLOR: Record< + NonNullable, + { 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 ( +
+
+ Геометрия участка +
+
{data.note}
+
+ ); + } + + const c = data.label ? LABEL_COLOR[data.label] : LABEL_COLOR["сложная форма"]; + const score = data.suitability_score ?? 0; + const scorePct = Math.round(score * 100); + + return ( +
+
+ + Геометрия участка + + + {data.label} · {scorePct}% + +
+ + {/* Метрики */} +
+
+
Площадь
+
+ {fmtArea(data.area_ha, data.area_m2)} +
+
+
+
Периметр
+
+ {data.perimeter_m ?? "—"} м +
+
+
+
+ Соотношение сторон +
+
+ {data.aspect_ratio?.toFixed(2) ?? "—"} +
+
+
+
+ Выпуклость +
+
+ {data.convex_hull_ratio !== undefined + ? `${(data.convex_hull_ratio * 100).toFixed(0)}%` + : "—"} +
+
+
+
+ Мин. ширина +
+
+ {data.min_inscribed_rect_dim_m ?? "—"} м +
+
+
+ + {/* Penalties */} + {data.penalties && data.penalties.length > 0 && ( +
+ ⚠ Проблемы формы: {data.penalties.join(", ")} +
+ )} + + {/* Recommendation */} + {data.recommendation && ( +
+ {data.recommendation} +
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx index 20273656..7e4e0d79 100644 --- a/frontend/src/components/site-finder/LandTab.tsx +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -2,6 +2,7 @@ import type { ParcelAnalysis } from "@/types/site-finder"; import { GeologyBlock } from "./GeologyBlock"; +import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock"; import { GeotechRiskBlock } from "./GeotechRiskBlock"; interface Props { @@ -9,10 +10,18 @@ interface 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 (
+ {/* P1 (#45) — Geometry suitability */} + {data.geometry_suitability !== undefined && ( + + )} + {/* Zoning note */}
Date: Tue, 12 May 2026 01:07:43 +0300 Subject: [PATCH 2/3] fix(site-finder): address PR #89 auto-review minor feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (parcels.py): 1. Фактическая ошибка фикс: recommendation теперь различает "строительный минимум 30м" (physical penalty trigger) и "комфорт МКД 12-16эт 40м" (recommendation level). Пользователю чётко видны 2 порога. 2. Lazy imports → module-level: `from shapely import wkt as _shp_wkt`, `from shapely.geometry import Polygon`. 3. MABR inner exception теперь логирует: `logger.debug("MABR computation failed, falling back to sqrt(area): %s", mabr_err)`. 4. Magic numbers вынесены в константы: _GEOM_MIN_AREA_HA, _GEOM_AREA_SCORE_FULL_HA, _GEOM_ASPECT_PENALTY_THRESHOLD, _GEOM_CONVEX_PENALTY_THRESHOLD, _GEOM_MIN_WIDTH_PHYSICAL_M, _GEOM_MIN_WIDTH_COMFORT_M, _GEOM_LABEL_MICRO_HA, _GEOM_LABEL_GOOD, _GEOM_LABEL_MEDIUM, плюс penalty константы. 5. Label "микро" теперь комбинируется с penalties: "микро, узкий" — UI видит обе проблемы. Pure "микро" остаётся когда нет penalty. Frontend (GeometrySuitabilityBlock.tsx + types): 6. Заменил emoji ⚠ (U+26A0) на текстовый "Проблемы формы:" — стабильнее в WeasyPrint PDF-экспорте и cross-platform. - TS типы: GeometrySuitabilityBaseLabel экспортирован, label расширен до `string` для допуска combo-labels; helper colorForLabel() парсит base часть. Per auto-review on f4e7491. --- backend/app/api/v1/parcels.py | 103 +++++++++++------- .../site-finder/GeometrySuitabilityBlock.tsx | 18 ++- frontend/src/types/site-finder.ts | 12 +- 3 files changed, 89 insertions(+), 44 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index c20d433a..bceef1e2 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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 @@ -274,31 +276,47 @@ 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_SCORE_FULL_HA = 0.5 # ≥ → area_subscore = 1.0 +_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 — площадь в гектарах (через UTM zone 41N для ЕКБ — точнее WGS84 deg²) + - 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: - - 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 (узкий) + 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: подходящий / сложная форма / слишком узкий / микро. + UI label: микро / подходящий / сложная форма / слабо подходит. Label "микро" + комбинируется с penalties — "микро · узкий" — чтобы пользователь увидел + обе проблемы сразу. """ try: - from shapely import wkt as _wkt - from shapely.geometry import Polygon - - # Парсим WGS84 polygon - poly = _wkt.loads(geom_wkt) + # Парсим 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"} @@ -307,15 +325,12 @@ def _polygon_suitability(geom_wkt: str) -> dict[str, Any]: 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км. + # 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 - # Преобразуем в метры: умножаем X на m_per_deg_lon, Y на m_per_deg_lat. - # Shapely не имеет встроенной reproj; работаем через scaling координат. ext = list(poly.exterior.coords) ext_m = [ ( @@ -337,7 +352,7 @@ def _polygon_suitability(geom_wkt: str) -> dict[str, Any]: try: mabr = poly_m.minimum_rotated_rectangle mabr_coords = list(mabr.exterior.coords) - # 4 уникальные точки в MABR → две стороны + # 4 уникальные точки в MABR (closed ring → 5 points) → две стороны side_lens: list[float] = [] for i in range(4): p1 = mabr_coords[i] @@ -346,38 +361,46 @@ def _polygon_suitability(geom_wkt: str) -> dict[str, Any]: 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: + 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 >= 0.5: + if area_ha >= _GEOM_AREA_SCORE_FULL_HA: area_subscore = 1.0 - elif area_ha <= 0.2: + elif area_ha <= _GEOM_MIN_AREA_HA: area_subscore = 0.0 else: - # linear 0.2 ha → 0, 0.5 ha → 1.0 - area_subscore = (area_ha - 0.2) / 0.3 + # 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 > 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 + 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 - if area_ha < 0.05: - label = "микро" - elif suitability >= 0.7: + # Label — combine "микро" с penalties чтобы UI видел всё + is_micro = area_ha < _GEOM_LABEL_MICRO_HA + if suitability >= _GEOM_LABEL_GOOD and not is_micro: label = "подходящий" - elif suitability >= 0.4: + elif is_micro: + # combine с penalties: "микро" + первая penalty (для краткости) + if penalties: + label = f"микро, {penalties[0].split(' (')[0]}" + else: + label = "микро" + elif suitability >= _GEOM_LABEL_MEDIUM: label = "сложная форма" else: label = "слабо подходит" @@ -393,7 +416,11 @@ def _polygon_suitability(geom_wkt: str) -> dict[str, Any]: "suitability_score": round(suitability, 2), "label": label, "penalties": penalties, - "recommendation": ("Для МКД 16+ этажей нужно >0.3 ha + минимальная ширина 40м."), + "recommendation": ( + f"Строительный минимум короткой стороны — {_GEOM_MIN_WIDTH_PHYSICAL_M}м, " + f"комфорт типового МКД 12-16 этажей — от {_GEOM_MIN_WIDTH_COMFORT_M}м " + f"и площадь от 0.3 га." + ), "note": ( "Оценка по форме участка (Shapely). Учитывает площадь, " "вытянутость, изрезанность, минимальную ширину MABR." diff --git a/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx b/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx index 69b161ec..a61103c0 100644 --- a/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx +++ b/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx @@ -1,13 +1,16 @@ "use client"; -import type { GeometrySuitability } from "@/types/site-finder"; +import type { + GeometrySuitability, + GeometrySuitabilityBaseLabel, +} from "@/types/site-finder"; interface Props { data: GeometrySuitability; } const LABEL_COLOR: Record< - NonNullable, + GeometrySuitabilityBaseLabel, { bg: string; fg: string; border: string } > = { подходящий: { bg: "#dcfce7", fg: "#15803d", border: "#86efac" }, @@ -16,6 +19,13 @@ const LABEL_COLOR: Record< микро: { 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)} га`; @@ -54,7 +64,7 @@ export function GeometrySuitabilityBlock({ data }: Props) { ); } - const c = data.label ? LABEL_COLOR[data.label] : LABEL_COLOR["сложная форма"]; + const c = colorForLabel(data.label); const score = data.suitability_score ?? 0; const scorePct = Math.round(score * 100); @@ -171,7 +181,7 @@ export function GeometrySuitabilityBlock({ data }: Props) { borderRadius: 6, }} > - ⚠ Проблемы формы: {data.penalties.join(", ")} + Проблемы формы: {data.penalties.join(", ")}
)} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 12391ad9..56a7f91f 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -144,7 +144,14 @@ export interface ParcelLocation { note: string; } -// P1 (#45) — physical suitability участка +// P1 (#45) — physical suitability участка. +// label — base value один из BaseLabel'ов либо combo "микро, " +export type GeometrySuitabilityBaseLabel = + | "микро" + | "подходящий" + | "сложная форма" + | "слабо подходит"; + export interface GeometrySuitability { data_available: boolean; area_ha?: number; @@ -154,7 +161,8 @@ export interface GeometrySuitability { convex_hull_ratio?: number; min_inscribed_rect_dim_m?: number; suitability_score?: number; - label?: "микро" | "подходящий" | "сложная форма" | "слабо подходит"; + // string — допускаем combo-label "микро, узкий"; см. GeometrySuitabilityBaseLabel + label?: string; penalties?: string[]; recommendation?: string; note: string; -- 2.45.3 From 5832dfae21a1028e9c1e722c0751e26cd5b34d39 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Tue, 12 May 2026 01:15:37 +0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(site-finder):=20P1=20#45=20=E2=80=94=20?= =?UTF-8?q?extract=200.3=20ha=20to=20=5FGEOM=5FAREA=5FCOMFORT=5FHA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-review нашёл: recommendation упоминала "от 0.3 га" но в constants block не было 0.3 — magic literal в строке. Тот же класс проблемы что 30/40м inconsistency из прошлого review, только в другом поле. Fix: новая константа `_GEOM_AREA_COMFORT_HA = 0.3` с комментарием "рекомендуемая комфортная площадь МКД (recommendation)". Размещена между _GEOM_MIN_AREA_HA (физический минимум) и _GEOM_AREA_SCORE_FULL_HA (premium) — третий semantic threshold. Recommendation теперь f-string использует константу. Per auto-review on ae5e8de. --- backend/app/api/v1/parcels.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index bceef1e2..c05c1134 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -278,8 +278,9 @@ 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_SCORE_FULL_HA = 0.5 # ≥ → area_subscore = 1.0 +_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 # ниже → изрезанный @@ -419,7 +420,7 @@ def _polygon_suitability(geom_wkt: str) -> dict[str, Any]: "recommendation": ( f"Строительный минимум короткой стороны — {_GEOM_MIN_WIDTH_PHYSICAL_M}м, " f"комфорт типового МКД 12-16 этажей — от {_GEOM_MIN_WIDTH_COMFORT_M}м " - f"и площадь от 0.3 га." + f"и площадь от {_GEOM_AREA_COMFORT_HA} га." ), "note": ( "Оценка по форме участка (Shapely). Учитывает площадь, " -- 2.45.3