fix(site-finder): address PR #89 auto-review minor feedback
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) на текстовый <strong>"Проблемы формы:"</strong> —
стабильнее в WeasyPrint PDF-экспорте и cross-platform.
- TS типы: GeometrySuitabilityBaseLabel экспортирован, label расширен до
`string` для допуска combo-labels; helper colorForLabel() парсит base часть.
Per auto-review on f4e7491.
This commit is contained in:
parent
f4e74916be
commit
ae5e8de1c7
3 changed files with 89 additions and 44 deletions
|
|
@ -5,6 +5,8 @@ from typing import Annotated, Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
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 import text
|
||||||
from sqlalchemy.orm import Session
|
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]:
|
def _polygon_suitability(geom_wkt: str) -> dict[str, Any]:
|
||||||
"""P1 (#45) — physical suitability участка по метрикам shape.
|
"""P1 (#45) — physical suitability участка по метрикам shape.
|
||||||
|
|
||||||
Метрики:
|
Метрики:
|
||||||
- area_ha — площадь в гектарах (через UTM zone 41N для ЕКБ — точнее WGS84 deg²)
|
- area_ha — площадь в гектарах (locally-projected metres via cos(lat))
|
||||||
- perimeter_m — периметр
|
- perimeter_m — периметр
|
||||||
- aspect_ratio — длина / ширина минимального ограничивающего прямоугольника
|
- aspect_ratio — длина / ширина минимального ограничивающего прямоугольника
|
||||||
- convex_hull_ratio — площадь / площадь выпуклой оболочки (1.0 = выпуклый, <0.7 изрезанный)
|
- convex_hull_ratio — площадь / площадь выпуклой оболочки (1.0 = выпуклый, <0.7 изрезанный)
|
||||||
- min_inscribed_rect_dim_m — длина короткой стороны MABR
|
- min_inscribed_rect_dim_m — длина короткой стороны MABR
|
||||||
|
|
||||||
Suitability score 0..1 — composite:
|
Suitability score 0..1 — composite (пороги — см. _GEOM_* константы):
|
||||||
- base 1.0
|
- area_subscore: <_GEOM_MIN_AREA_HA → 0.0, ≥_GEOM_AREA_SCORE_FULL_HA → 1.0, linear
|
||||||
- площадь: <0.2 ha → 0.0, ≥0.5 ha → 1.0, linear в промежутке
|
- −_GEOM_ASPECT_PENALTY если aspect_ratio > _GEOM_ASPECT_PENALTY_THRESHOLD
|
||||||
- −0.3 если aspect_ratio > 5 (вытянутый)
|
- −_GEOM_CONVEX_PENALTY если convex_hull_ratio < _GEOM_CONVEX_PENALTY_THRESHOLD
|
||||||
- −0.3 если convex_hull_ratio < 0.65 (изрезанный)
|
- −_GEOM_NARROW_PENALTY если short_side < _GEOM_MIN_WIDTH_PHYSICAL_M
|
||||||
- −0.5 если min_inscribed_rect_dim_m < 30 (узкий)
|
|
||||||
|
|
||||||
UI label: подходящий / сложная форма / слишком узкий / микро.
|
UI label: микро / подходящий / сложная форма / слабо подходит. Label "микро"
|
||||||
|
комбинируется с penalties — "микро · узкий" — чтобы пользователь увидел
|
||||||
|
обе проблемы сразу.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from shapely import wkt as _wkt
|
# Парсим WGS84 polygon (shapely imports теперь module-level)
|
||||||
from shapely.geometry import Polygon
|
poly = _shp_wkt.loads(geom_wkt)
|
||||||
|
|
||||||
# Парсим WGS84 polygon
|
|
||||||
poly = _wkt.loads(geom_wkt)
|
|
||||||
if poly.is_empty or poly.geom_type not in ("Polygon", "MultiPolygon"):
|
if poly.is_empty or poly.geom_type not in ("Polygon", "MultiPolygon"):
|
||||||
return {"data_available": False, "note": "Геометрия не 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)
|
poly = max(poly.geoms, key=lambda g: g.area)
|
||||||
assert isinstance(poly, Polygon)
|
assert isinstance(poly, Polygon)
|
||||||
|
|
||||||
# Точная площадь — проектируем в UTM zone 41N (ЕКБ 60°E), EPSG:32641
|
# Equirectangular-projection в метры через centroid-anchor.
|
||||||
# Простая оценка: на широте ~57° 1° долготы ≈ 60.5 км, 1° широты ≈ 111 км.
|
# На широте ~57° деформация <1% в радиусе 50км (parcel-scale OK).
|
||||||
# Для парcel-level scale достаточно — деформация <1% в радиусе 50км.
|
|
||||||
centroid = poly.centroid
|
centroid = poly.centroid
|
||||||
lat_rad = math.radians(centroid.y)
|
lat_rad = math.radians(centroid.y)
|
||||||
m_per_deg_lon = 111_320.0 * math.cos(lat_rad)
|
m_per_deg_lon = 111_320.0 * math.cos(lat_rad)
|
||||||
m_per_deg_lat = 110_540.0
|
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 = list(poly.exterior.coords)
|
||||||
ext_m = [
|
ext_m = [
|
||||||
(
|
(
|
||||||
|
|
@ -337,7 +352,7 @@ def _polygon_suitability(geom_wkt: str) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
mabr = poly_m.minimum_rotated_rectangle
|
mabr = poly_m.minimum_rotated_rectangle
|
||||||
mabr_coords = list(mabr.exterior.coords)
|
mabr_coords = list(mabr.exterior.coords)
|
||||||
# 4 уникальные точки в MABR → две стороны
|
# 4 уникальные точки в MABR (closed ring → 5 points) → две стороны
|
||||||
side_lens: list[float] = []
|
side_lens: list[float] = []
|
||||||
for i in range(4):
|
for i in range(4):
|
||||||
p1 = mabr_coords[i]
|
p1 = mabr_coords[i]
|
||||||
|
|
@ -346,38 +361,46 @@ def _polygon_suitability(geom_wkt: str) -> dict[str, Any]:
|
||||||
short_side = min(side_lens)
|
short_side = min(side_lens)
|
||||||
long_side = max(side_lens)
|
long_side = max(side_lens)
|
||||||
aspect_ratio = long_side / short_side if short_side > 0 else 1.0
|
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)
|
short_side = math.sqrt(area_m2)
|
||||||
aspect_ratio = 1.0
|
aspect_ratio = 1.0
|
||||||
|
|
||||||
# Suitability score composite
|
# Suitability score composite
|
||||||
if area_ha >= 0.5:
|
if area_ha >= _GEOM_AREA_SCORE_FULL_HA:
|
||||||
area_subscore = 1.0
|
area_subscore = 1.0
|
||||||
elif area_ha <= 0.2:
|
elif area_ha <= _GEOM_MIN_AREA_HA:
|
||||||
area_subscore = 0.0
|
area_subscore = 0.0
|
||||||
else:
|
else:
|
||||||
# linear 0.2 ha → 0, 0.5 ha → 1.0
|
# linear: _GEOM_MIN_AREA_HA → 0, _GEOM_AREA_SCORE_FULL_HA → 1.0
|
||||||
area_subscore = (area_ha - 0.2) / 0.3
|
area_subscore = (area_ha - _GEOM_MIN_AREA_HA) / (
|
||||||
|
_GEOM_AREA_SCORE_FULL_HA - _GEOM_MIN_AREA_HA
|
||||||
|
)
|
||||||
|
|
||||||
suitability = area_subscore
|
suitability = area_subscore
|
||||||
penalties: list[str] = []
|
penalties: list[str] = []
|
||||||
if aspect_ratio > 5:
|
if aspect_ratio > _GEOM_ASPECT_PENALTY_THRESHOLD:
|
||||||
suitability -= 0.3
|
suitability -= _GEOM_ASPECT_PENALTY
|
||||||
penalties.append("вытянутый (aspect>5)")
|
penalties.append(f"вытянутый (aspect>{_GEOM_ASPECT_PENALTY_THRESHOLD:.0f})")
|
||||||
if convex_hull_ratio < 0.65:
|
if convex_hull_ratio < _GEOM_CONVEX_PENALTY_THRESHOLD:
|
||||||
suitability -= 0.3
|
suitability -= _GEOM_CONVEX_PENALTY
|
||||||
penalties.append("изрезанный (convex<0.65)")
|
penalties.append(f"изрезанный (convex<{_GEOM_CONVEX_PENALTY_THRESHOLD})")
|
||||||
if short_side < 30:
|
if short_side < _GEOM_MIN_WIDTH_PHYSICAL_M:
|
||||||
suitability -= 0.5
|
suitability -= _GEOM_NARROW_PENALTY
|
||||||
penalties.append(f"узкий (короткая сторона {short_side:.0f}м)")
|
penalties.append(f"узкий (короткая сторона {short_side:.0f}м)")
|
||||||
suitability = max(0.0, min(1.0, suitability))
|
suitability = max(0.0, min(1.0, suitability))
|
||||||
|
|
||||||
# Label
|
# Label — combine "микро" с penalties чтобы UI видел всё
|
||||||
if area_ha < 0.05:
|
is_micro = area_ha < _GEOM_LABEL_MICRO_HA
|
||||||
label = "микро"
|
if suitability >= _GEOM_LABEL_GOOD and not is_micro:
|
||||||
elif suitability >= 0.7:
|
|
||||||
label = "подходящий"
|
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 = "сложная форма"
|
label = "сложная форма"
|
||||||
else:
|
else:
|
||||||
label = "слабо подходит"
|
label = "слабо подходит"
|
||||||
|
|
@ -393,7 +416,11 @@ def _polygon_suitability(geom_wkt: str) -> dict[str, Any]:
|
||||||
"suitability_score": round(suitability, 2),
|
"suitability_score": round(suitability, 2),
|
||||||
"label": label,
|
"label": label,
|
||||||
"penalties": penalties,
|
"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": (
|
"note": (
|
||||||
"Оценка по форме участка (Shapely). Учитывает площадь, "
|
"Оценка по форме участка (Shapely). Учитывает площадь, "
|
||||||
"вытянутость, изрезанность, минимальную ширину MABR."
|
"вытянутость, изрезанность, минимальную ширину MABR."
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { GeometrySuitability } from "@/types/site-finder";
|
import type {
|
||||||
|
GeometrySuitability,
|
||||||
|
GeometrySuitabilityBaseLabel,
|
||||||
|
} from "@/types/site-finder";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: GeometrySuitability;
|
data: GeometrySuitability;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LABEL_COLOR: Record<
|
const LABEL_COLOR: Record<
|
||||||
NonNullable<GeometrySuitability["label"]>,
|
GeometrySuitabilityBaseLabel,
|
||||||
{ bg: string; fg: string; border: string }
|
{ bg: string; fg: string; border: string }
|
||||||
> = {
|
> = {
|
||||||
подходящий: { bg: "#dcfce7", fg: "#15803d", border: "#86efac" },
|
подходящий: { bg: "#dcfce7", fg: "#15803d", border: "#86efac" },
|
||||||
|
|
@ -16,6 +19,13 @@ const LABEL_COLOR: Record<
|
||||||
микро: { 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 {
|
function fmtArea(ha: number | undefined, m2: number | undefined): string {
|
||||||
if (ha !== undefined && ha >= 0.1) {
|
if (ha !== undefined && ha >= 0.1) {
|
||||||
return `${ha.toFixed(2)} га`;
|
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 score = data.suitability_score ?? 0;
|
||||||
const scorePct = Math.round(score * 100);
|
const scorePct = Math.round(score * 100);
|
||||||
|
|
||||||
|
|
@ -171,7 +181,7 @@ export function GeometrySuitabilityBlock({ data }: Props) {
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
⚠ Проблемы формы: {data.penalties.join(", ")}
|
<strong>Проблемы формы:</strong> {data.penalties.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,14 @@ export interface ParcelLocation {
|
||||||
note: string;
|
note: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// P1 (#45) — physical suitability участка
|
// P1 (#45) — physical suitability участка.
|
||||||
|
// label — base value один из BaseLabel'ов либо combo "микро, <first-penalty>"
|
||||||
|
export type GeometrySuitabilityBaseLabel =
|
||||||
|
| "микро"
|
||||||
|
| "подходящий"
|
||||||
|
| "сложная форма"
|
||||||
|
| "слабо подходит";
|
||||||
|
|
||||||
export interface GeometrySuitability {
|
export interface GeometrySuitability {
|
||||||
data_available: boolean;
|
data_available: boolean;
|
||||||
area_ha?: number;
|
area_ha?: number;
|
||||||
|
|
@ -154,7 +161,8 @@ export interface GeometrySuitability {
|
||||||
convex_hull_ratio?: number;
|
convex_hull_ratio?: number;
|
||||||
min_inscribed_rect_dim_m?: number;
|
min_inscribed_rect_dim_m?: number;
|
||||||
suitability_score?: number;
|
suitability_score?: number;
|
||||||
label?: "микро" | "подходящий" | "сложная форма" | "слабо подходит";
|
// string — допускаем combo-label "микро, узкий"; см. GeometrySuitabilityBaseLabel
|
||||||
|
label?: string;
|
||||||
penalties?: string[];
|
penalties?: string[];
|
||||||
recommendation?: string;
|
recommendation?: string;
|
||||||
note: string;
|
note: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue