Заменяет generative stubs детерминированным end-to-end пайплайном: - 1a geometry: WGS84-parse, метрическая AEQD-репроекция, setback-буфер, placement grid - 1b placement: greedy section-fill + STRtree коллизии (full-gap), 3 стратегии, coverage cap - 1c teap/financial: площади/квартиры/FAR/паркинг; выручка/затраты/маржа + IRR-proxy - exporters: ezdxf DXF (геометрия) + WeasyPrint ТЭП/фин PDF (lazy import) generate() заменил generate_stub в route (422 на degenerate). mypy-strict + ruff clean, 31 generative-тест + full suite 3078 passed. ConceptVariant заполняется реальными числами. Closes #54 Closes #55 Closes #56
237 lines
10 KiB
Python
237 lines
10 KiB
Python
"""Generative Design — Stage 1b: greedy section placement with STRtree collisions.
|
||
|
||
Given a parsed :class:`~app.services.generative.geometry.Parcel` (Stage 1a) we place
|
||
rectangular residential sections (секции МКД) onto the placement grid using a greedy
|
||
sweep. Three strategies trade plot density against insolation comfort:
|
||
|
||
* ``max_area`` — tight gaps, deep building footprint -> maximum buildable area.
|
||
* ``max_insolation`` — wide gaps + slimmer footprint -> light/air between buildings.
|
||
* ``balanced`` — the middle ground.
|
||
|
||
Collisions (overlap + minimum inter-section gap) are checked with a Shapely STRtree
|
||
spatial index, rebuilt as placements accumulate. The greedy sweep is fully
|
||
deterministic: candidate anchors are visited in a fixed grid order and the first
|
||
non-colliding footprint that stays inside the buildable area wins.
|
||
|
||
Each placed footprint is reprojected back to WGS84 and emitted as a GeoJSON Feature
|
||
in ``ConceptVariant.buildings_geojson``; the metric footprints feed Stage 1c
|
||
(``teap`` + ``financial``) so the variant is filled with real numbers, not zeros.
|
||
|
||
Deterministic, no LLM / no external API / no DB.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from dataclasses import dataclass
|
||
|
||
from shapely.geometry import Polygon, box
|
||
from shapely.strtree import STRtree
|
||
|
||
from app.schemas.concept import ConceptInput, ConceptVariant
|
||
from app.services.generative import financial, teap
|
||
from app.services.generative.geometry import Parcel
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Тип стратегии должен совпадать с Literal в ConceptVariant.strategy.
|
||
StrategyName = str
|
||
|
||
# Высота этажа (м) — для перевода target_floors в метрическую высоту/площади.
|
||
FLOOR_HEIGHT_M: float = 3.0
|
||
|
||
# ── Потолок коэффициента застройки (built / buildable) по типу застройки ──────
|
||
# Контракт не несёт явного FAR/max_coverage, поэтому используем development_type как
|
||
# естественный регулятор плотности. Жадная раскладка перестаёт ставить секции, как
|
||
# только пятно достигает доли buildable area ниже. Без этого max_area патологически
|
||
# забивает участок и даёт нереалистичный FAR. MVP-упрощение (нормативный proxy).
|
||
_COVERAGE_CAP_BY_TYPE: dict[str, float] = {
|
||
"spot": 0.35, # точечная застройка — низкое покрытие
|
||
"mid_rise": 0.45, # среднеэтажная
|
||
"high_rise": 0.50, # высотная — компактнее пятно, выше этажность
|
||
}
|
||
_DEFAULT_COVERAGE_CAP: float = 0.45
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class StrategySpec:
|
||
"""Параметры одной стратегии размещения.
|
||
|
||
section_w/section_d — габариты секции (ширина x глубина), метры.
|
||
gap_m — минимальный разрыв между секциями (инсоляция/противопожарный), метры.
|
||
floors_factor — множитель к target_floors (комфорт-класс «садит» этажность,
|
||
макс-площадь «тянет» вверх); этажность клампится к [1, 30] контракта.
|
||
"""
|
||
|
||
name: StrategyName
|
||
section_w: float
|
||
section_d: float
|
||
gap_m: float
|
||
floors_factor: float
|
||
|
||
|
||
# ── Три стратегии (упрощённо для MVP, габариты типовых панельных/монолитных секций) ──
|
||
_STRATEGIES: tuple[StrategySpec, ...] = (
|
||
# Максимум площади: глубокий корпус, минимальные противопожарные разрывы.
|
||
StrategySpec(name="max_area", section_w=24.0, section_d=18.0, gap_m=6.0, floors_factor=1.15),
|
||
# Максимум инсоляции: тонкий корпус, широкие разрывы между секциями.
|
||
StrategySpec(
|
||
name="max_insolation", section_w=18.0, section_d=12.0, gap_m=15.0, floors_factor=0.85
|
||
),
|
||
# Баланс.
|
||
StrategySpec(name="balanced", section_w=21.0, section_d=15.0, gap_m=10.0, floors_factor=1.0),
|
||
)
|
||
|
||
_FLOORS_MIN = 1
|
||
_FLOORS_MAX = 30
|
||
|
||
|
||
def _resolve_floors(target_floors: int, factor: float) -> int:
|
||
"""target_floors * factor, округление к ближайшему, клампинг к [1, 30]."""
|
||
floors = round(target_floors * factor)
|
||
return max(_FLOORS_MIN, min(_FLOORS_MAX, floors))
|
||
|
||
|
||
def _greedy_place(
|
||
parcel: Parcel,
|
||
spec: StrategySpec,
|
||
coverage_cap: float,
|
||
) -> list[Polygon]:
|
||
"""Жадно разложить секции ``spec`` по сетке участка. Возвращает footprints (метры).
|
||
|
||
Алгоритм:
|
||
* кандидат-якоря — центры ячеек сетки в фиксированном порядке;
|
||
* footprint строится центрированно на якоре;
|
||
* принимается, если целиком внутри buildable area И не нарушает разрыв ``gap_m``
|
||
с уже принятыми (проверка через STRtree по buffered-footprints);
|
||
* раскладка останавливается, когда пятно достигает ``coverage_cap`` от buildable
|
||
area (регулятор плотности по типу застройки) — это также ограничивает число
|
||
размещений и держит O(n^2)-перестройку STRtree в бюджете.
|
||
"""
|
||
buildable = parcel.buildable_m
|
||
max_built = buildable.area * coverage_cap
|
||
placed: list[Polygon] = []
|
||
built_area = 0.0
|
||
# Буферизованные footprints для проверки разрыва; индекс STRtree по ним.
|
||
buffered: list[Polygon] = []
|
||
tree: STRtree | None = None
|
||
|
||
half_w = spec.section_w / 2.0
|
||
half_d = spec.section_d / 2.0
|
||
half_gap = spec.gap_m / 2.0
|
||
|
||
for cell in parcel.grid:
|
||
if built_area >= max_built:
|
||
break
|
||
footprint = box(
|
||
cell.cx - half_w,
|
||
cell.cy - half_d,
|
||
cell.cx + half_w,
|
||
cell.cy + half_d,
|
||
)
|
||
# Целиком внутри пятна застройки (covers допускает касание границы).
|
||
if not buildable.covers(footprint):
|
||
continue
|
||
|
||
# Разрыв между секциями: буферим кандидата на half_gap и проверяем пересечение
|
||
# с буферизованными соседями — две секции с зазором >= gap_m не пересекутся.
|
||
candidate_buf = footprint.buffer(half_gap, join_style="mitre")
|
||
if tree is not None:
|
||
collision = False
|
||
for idx in tree.query(candidate_buf):
|
||
if candidate_buf.intersects(buffered[idx]):
|
||
collision = True
|
||
break
|
||
if collision:
|
||
continue
|
||
|
||
placed.append(footprint)
|
||
built_area += footprint.area
|
||
buffered.append(candidate_buf)
|
||
tree = STRtree(buffered)
|
||
|
||
logger.info(
|
||
"strategy=%s placed %d sections (%.0fx%.0f m, gap=%.0f m, coverage<=%.0f%%)",
|
||
spec.name,
|
||
len(placed),
|
||
spec.section_w,
|
||
spec.section_d,
|
||
spec.gap_m,
|
||
coverage_cap * 100,
|
||
)
|
||
return placed
|
||
|
||
|
||
def _footprints_to_geojson(
|
||
parcel: Parcel,
|
||
footprints: list[Polygon],
|
||
floors: int,
|
||
spec: StrategySpec,
|
||
) -> dict[str, object]:
|
||
"""Метрические footprints -> WGS84 FeatureCollection (контракт buildings_geojson)."""
|
||
features: list[dict[str, object]] = []
|
||
for i, fp in enumerate(footprints):
|
||
geom_wgs = parcel.metric_geom_to_wgs84(fp)
|
||
features.append(
|
||
{
|
||
"type": "Feature",
|
||
"geometry": geom_wgs,
|
||
"properties": {
|
||
"section_id": i + 1,
|
||
"floors": floors,
|
||
"footprint_sqm": round(float(fp.area), 1),
|
||
"strategy": spec.name,
|
||
},
|
||
}
|
||
)
|
||
return {"type": "FeatureCollection", "features": features}
|
||
|
||
|
||
def place_strategy(
|
||
parcel: Parcel,
|
||
payload: ConceptInput,
|
||
spec: StrategySpec,
|
||
) -> ConceptVariant:
|
||
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant."""
|
||
floors = _resolve_floors(payload.target_floors, spec.floors_factor)
|
||
coverage_cap = _COVERAGE_CAP_BY_TYPE.get(payload.development_type, _DEFAULT_COVERAGE_CAP)
|
||
footprints = _greedy_place(parcel, spec, coverage_cap)
|
||
|
||
teap_result = teap.compute_teap(
|
||
footprints=footprints,
|
||
floors=floors,
|
||
site_area_sqm=parcel.site_area_sqm,
|
||
housing_class=payload.housing_class,
|
||
)
|
||
financial_result = financial.compute_financial(
|
||
teap=teap_result,
|
||
housing_class=payload.housing_class,
|
||
land_cost_rub=payload.land_cost_rub,
|
||
)
|
||
buildings_geojson = _footprints_to_geojson(parcel, footprints, floors, spec)
|
||
|
||
# spec.name строится из фиксированного литерала -> совпадает с Literal контракта.
|
||
return ConceptVariant(
|
||
strategy=spec.name, # type: ignore[arg-type]
|
||
buildings_geojson=buildings_geojson,
|
||
teap=teap_result,
|
||
financial=financial_result,
|
||
)
|
||
|
||
|
||
def place_all_strategies(parcel: Parcel, payload: ConceptInput) -> list[ConceptVariant]:
|
||
"""Stage 1b entry: построить три варианта (max_area / max_insolation / balanced)."""
|
||
variants = [place_strategy(parcel, payload, spec) for spec in _STRATEGIES]
|
||
logger.info(
|
||
"placed all strategies: %s",
|
||
", ".join(f"{v.strategy}={v.teap.apartments_count}кв" for v in variants),
|
||
)
|
||
return variants
|
||
|
||
|
||
__all__ = [
|
||
"FLOOR_HEIGHT_M",
|
||
"StrategySpec",
|
||
"place_all_strategies",
|
||
"place_strategy",
|
||
]
|