From ae5e8de1c721ec5da5c22c258273dbcbc97d9187 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Tue, 12 May 2026 01:07:43 +0300 Subject: [PATCH] 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;