gendesign/backend/app/schemas/parcel.py
lekss361 bcd7dc8f75 fix(sf-09): iterative cap algorithm + cap_skipped flag + unit tests
Review verdict MINOR (PR #282) — single-pass cap_and_redistribute
не гарантировал invariant max ≤ MAX_BUCKET_SHARE_PCT. Surplus от clamped
поднимал free выше cap (например {1k:75, studio:15, 2k:10} → studio=39).

- backend/app/services/site_finder/best_layouts.py
  - _cap_and_redistribute → iterative while-loop до сходимости
  - Returns (result_map, cap_skipped) — флаг для pathological all-clamped
  - Float work map, Hamilton финальный pass для invariant sum=100
- backend/app/schemas/parcel.py
  - LayoutTzRecommendation.cap_skipped: bool = False
- backend/tests/services/site_finder/test_best_layouts.py
  - 6 parametrized tests: invariants, 7 failing cases from review,
    no-dominant unchanged, empty, cap_skipped propagation, normal-case
- frontend/src/types/best-layouts.ts — cap_skipped: boolean
- frontend/src/components/site-finder/BestLayoutsBlock.tsx
  - banner условие сменилось с maxPct > 60 на rec.cap_skipped (honest signal)
2026-05-17 15:21:51 +03:00

358 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import datetime as dt
from typing import Any, Literal
from pydantic import BaseModel, Field
# ── #105 Phase 5: Recent permits schemas ──────────────────────────────────────
class RecentPermit(BaseModel):
"""Одно строительное разрешение (РНС или РВЭ) из ekburg_construction_permits."""
permit_type: str
permit_number: str
issue_date: str | None
developer_name: str | None
developer_inn: str | None
object_name: str | None
object_type: str | None
construction_address: str | None
total_area_sqm: float | None
class PermitsSummary(BaseModel):
"""Агрегированная сводка по разрешениям в квартале."""
rns_count: int
rve_count: int
rns_total_area_sqm: float
by_developer: list[dict[str, Any]]
# ── Connection points schemas (issue #115) ────────────────────────────────────
class EngineeringStructure(BaseModel):
name: str | None
type: str | None
cad_num: str | None
distance_to_boundary_m: float
geometry_geojson: dict[str, Any]
readable_address: str | None
raw_props: dict[str, Any]
source: str
class ZouitOverlap(BaseModel):
reg_numb_border: str | None
type_zone: str | None
subcategory: int | None
intersects_parcel: bool
geometry_geojson: dict[str, Any]
raw_props: dict[str, Any]
source: str
class ConnectionPointsSummary(BaseModel):
nearest_structure_distance_m: float | None
in_protection_zone: bool
protection_zones_intersecting: int
total_structures_in_radius: int
class ConnectionPointsResponse(BaseModel):
engineering_structures: list[EngineeringStructure]
zouit_engineering_overlaps: list[ZouitOverlap]
summary: ConnectionPointsSummary
dump_available: bool
dump_fetched_at: str | None
# ── NSPD Risk Zones schemas (issue #94 TIER 3) ───────────────────────────────
class RiskZone(BaseModel):
"""Одна риск-зона НСПД (TIER 3), пересекающая участок."""
layer: str # e.g. "risk_flooding", "risk_landslide"
subtype: str | None # человекочитаемое название из NSPD properties
geom_wkt: str | None # WKT геометрии risk-зоны в EPSG:4326
intersection_area_sqm: float | None # площадь пересечения с участком, м²
# ── NSPD Opportunity + Red Lines schemas (issue #94 TIER 4) ──────────────────
class OpportunityParcel(BaseModel):
"""TIER 4: opportunity ЗУ вблизи участка (auction/scheme/free/future/oopt).
Поля:
layer: тип opportunity — "auction_parcels" | "scheme_parcels" |
"free_parcels" | "future_parcels" | "oopt"
cad_num: кадастровый номер ЗУ (если доступен в NSPD properties)
distance_m: расстояние от centroid анализируемого участка до ЗУ, м
geom_wkt: WKT геометрии ЗУ в EPSG:4326
"""
layer: str
cad_num: str | None
distance_m: float | None
geom_wkt: str | None
class RedLine(BaseModel):
"""TIER 4: красная линия застройки (NSPD layer 879243).
Поля:
geom_wkt: WKT геометрии линии в EPSG:4326
intersection_length_m: длина пересечения линии с участком, м
(null если только nearby, не intersect)
distance_m: расстояние от ближайшей точки линии до участка, м
(null если линия пересекает участок)
UI-семантика:
intersection_length_m != null → красная линия ПЕРЕСЕКАЕТ участок (ALERT)
distance_m != null → красная линия рядом (warning)
"""
geom_wkt: str | None
intersection_length_m: float | None # null если только nearby, не intersect
distance_m: float | None # null если intersect
# ── Parcel search/detail schemas ──────────────────────────────────────────────
class ParcelFilter(BaseModel):
vri: list[str] | None = None
area_min_sqm: float | None = None
area_max_sqm: float | None = None
region: str | None = None
polygon_geojson: dict[str, Any] | None = None
text_search: str | None = None
class ScoringWeights(BaseModel):
distance_to_center: float = 0.4
poi_density: float = 0.3
neighborhood_price: float = 0.3
class ParcelSearchRequest(BaseModel):
filters: ParcelFilter = Field(default_factory=ParcelFilter)
weights: ScoringWeights = Field(default_factory=ScoringWeights)
limit: int = 50
class ScoreBreakdown(BaseModel):
total: float
distance_to_center: float
poi_density: float
neighborhood_price: float
class ParcelSummary(BaseModel):
id: str
cadastral_number: str
area_sqm: float
vri: str
address: str | None
score: ScoreBreakdown
class ParcelSearchResponse(BaseModel):
items: list[ParcelSummary]
total: int
class ParcelDetail(ParcelSummary):
geometry_geojson: dict[str, Any]
enrichment: dict[str, Any]
# ── Competitors endpoint schemas ──────────────────────────────────────────────
TimeWindow = Literal["last_month", "last_quarter", "last_year"]
ObjClassFilter = Literal["economy", "comfort", "business"]
class CompetitorsRequest(BaseModel):
radius_km: float = Field(default=1.0, ge=0.1, le=1.5)
time_window: TimeWindow = "last_quarter"
obj_class_filter: ObjClassFilter | None = None
exclude_obj_ids: list[int] = Field(default_factory=list)
class Competitor(BaseModel):
obj_id: int
comm_name: str | None
dev_name: str | None
obj_class: str | None
distance_m: float
lat: float
lng: float
stage: str | None
site_status: str | None = None
ready_dt: dt.date | None = None
flats_total: int | None
flats_sold: int | None
sold_pct: float | None
velocity_per_month: float
avg_price_per_m2: float | None
is_active: bool
class CompetitorsSummary(BaseModel):
total_competitors: int
active_count: int
weighted_avg_velocity: float
radius_km: float
time_window: str
class CompetitorsResponse(BaseModel):
competitors: list[Competitor]
summary: CompetitorsSummary
# ── Layout analysis (Issue #113) ───────────────────────────────────────────
class LayoutSignature(BaseModel):
"""Минимальная сигнатура планировки = (room_bucket, area_bin).
Phase 2.1: layout_type/balcony_count в БД нет, ждут B2B Объектив (#52).
"""
room_bucket: Literal["studio", "1", "2", "3", "4+"]
area_bin: Literal["<25", "25-40", "40-60", "60-80", "80-100", "100+"]
class BestLayoutsRequest(BaseModel):
"""Параметры запроса top-планировок в радиусе вокруг участка."""
radius_km: float = Field(default=1.0, ge=0.1, le=1.5)
time_window: Literal["last_month", "last_quarter", "last_year"] = "last_quarter"
filter_competitor_obj_ids: list[int] | None = None
exclude_competitor_obj_ids: list[int] = Field(default_factory=list)
min_velocity_per_month: float = Field(default=0.5, ge=0.0, le=100.0)
obj_class_filter: Literal["economy", "comfort", "business"] | None = None
target_total_flats: int | None = Field(default=None, ge=1, le=10000)
class TopLayoutRow(BaseModel):
"""Одна строка top-planirovok ranking'а."""
rank: int
room_bucket: str
area_bin: str
signature: str
competitor_obj_ids: list[int]
competitor_count: int
total_sold_in_window: int
velocity_per_month: float
avg_price_per_m2_rub: float | None # NULL если objective не покрывает obj
avg_area_m2: float
supply_units_in_radius: int
sold_pct_of_supply: float | None # NULL если supply=0; clamped at 100.0
is_oversold: bool # True когда raw sum_deals/supply > 100% (несопоставимые окна)
class LayoutTzMixRow(BaseModel):
"""Строка рекомендации unit-mix для ТЗ."""
room_bucket: str
pct: int # 0..100, sum total = 100
abs_units: int | None # NULL если target_total_flats не задан
avg_target_area_m2: float | None
class LayoutTzRecommendation(BaseModel):
"""Рекомендация для ТЗ на проектирование."""
rationale_text: str
mix: list[LayoutTzMixRow]
weighted_avg_price_per_m2_rub: float | None
based_on_obj_count: int
based_on_total_deals: int
data_window_start: dt.date
data_window_end: dt.date
# Fix SF-09 review: True если pathological case — все bucket'ы выше cap,
# redistribute невозможен. Frontend использует для отображения warning banner.
cap_skipped: bool = False
class LayoutDataQuality(BaseModel):
"""Метаданные качества данных (coverage)."""
objects_with_velocity_data: int
objects_total_in_radius: int
velocity_coverage_pct: float
confidence: Literal["high", "medium", "low"]
class BestLayoutsResponse(BaseModel):
"""Ответ /best-layouts endpoint'а."""
top_layouts: list[TopLayoutRow]
recommendation_for_tz: LayoutTzRecommendation
data_quality: LayoutDataQuality
# ── Analyze endpoint market price (#33) ──────────────────────────────────────
class MarketPrice(BaseModel):
"""Ценовая статистика квартала из mv_quarter_price_per_m2 (Issue #33).
Источник: rosreestr_deals, фильтр realestate_type_code='002001003000' (ДДУ),
скользящее окно 24 мес., 30K800K руб/м², HAVING >= 3 сделок.
"""
p25: float | None = None
median: float | None = None
p75: float | None = None
mean: float | None = None
deals_count: int = 0
median_6m: float | None = None
median_12m: float | None = None
median_24m: float | None = None
last_deal_date: str | None = None # ISO date
source: Literal["quarter_mv", "no_data"] = "no_data"
# ── Analyze endpoint parcel meta (#29 G2) ────────────────────────────────────
class ParcelMeta(BaseModel):
"""Кадастровые метаданные участка из cad_parcels (#29 G2).
Поля берутся из NSPD bulk-ingest (issue #168). Строки сырые, без нормализации.
"""
permitted_use: str | None = None # permitted_use_established_by_document
land_category: str | None = None # land_record_category_type
land_subtype: str | None = None # land_record_subtype
cad_cost: float | None = None # cost_value (кадастровая стоимость, руб.)
source: str = "cad_parcels"
# ── Analyze endpoint inline weights (#201) ────────────────────────────────────
class AnalyzeRequest(BaseModel):
"""Опциональное тело запроса POST /analyze.
Позволяет передать inline POI-веса напрямую в запросе без сохранения
профиля. Если задан weights — применяется с наивысшим приоритетом
(выше profile_id и user default).
"""
weights: dict[str, float] | None = Field(
default=None,
description=(
"Inline POI weights override (категория → weight). "
"Если задан — применяется к scoring, без обязательного profile save. "
"Validated против ALLOWED_CATEGORIES + MIN_WEIGHT/MAX_WEIGHT."
),
)