gendesign/backend/app/services/generative/placement.py
bot-backend 975c400536
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 1m57s
Deploy / build-frontend (push) Successful in 3m34s
Deploy / build-worker (push) Successful in 3m41s
Deploy / deploy (push) Successful in 1m32s
feat(section7): editable section footprint + корпус plural/program hint (#1953) (#2102)
2026-06-30 09:48:11 +00:00

605 lines
28 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.

"""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
import math
from dataclasses import dataclass
from shapely.geometry import Polygon, box
from shapely.strtree import STRtree
from app.schemas.concept import (
TEAP,
BuildingProgramItem,
ConceptInput,
ConceptVariant,
)
from app.services.generative import catalog, financial, teap
from app.services.generative.geometry import Parcel, ParcelGeometryError
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))
class _Placer:
"""Аккумулятор размещённых секций + STRtree-индекс для проверки разрывов.
Извлечён из жадной раскладки, чтобы и :func:`_greedy_place` (coverage-cap sweep), и
:func:`place_program` (фиксированная программа типовых домов, Stage 3a) пользовались
ОДНОЙ И ТОЙ ЖЕ collision/setback-машиной, а не дублировали её. Состояние: принятые
footprints, их буферизованные на ``half_gap`` версии и перестраиваемый по ним STRtree.
"""
def __init__(self) -> None:
self.placed: list[Polygon] = []
self.built_area: float = 0.0
# Буферизованные footprints для проверки разрыва; индекс STRtree по ним.
self._buffered: list[Polygon] = []
self._tree: STRtree | None = None
def try_place(self, footprint: Polygon, buildable: Polygon, half_gap: float) -> bool:
"""Попытаться принять ``footprint``. True — принят, False — не лёг.
Принимается, если целиком внутри ``buildable`` (covers допускает касание границы)
И не нарушает разрыв ``half_gap*2`` с уже принятыми (буферим кандидата на
``half_gap`` и проверяем пересечение с буферизованными соседями через STRtree —
две секции с зазором >= gap не пересекутся).
"""
if not buildable.covers(footprint):
return False
candidate_buf = footprint.buffer(half_gap, join_style="mitre")
if self._tree is not None:
for idx in self._tree.query(candidate_buf):
if candidate_buf.intersects(self._buffered[idx]):
return False
self.placed.append(footprint)
self.built_area += footprint.area
self._buffered.append(candidate_buf)
self._tree = STRtree(self._buffered)
return True
def _centered_footprint(cx: float, cy: float, width: float, depth: float) -> Polygon:
"""Прямоугольное пятно секции ``width × depth``, центрированное на (cx, cy), метры."""
half_w = width / 2.0
half_d = depth / 2.0
return box(cx - half_w, cy - half_d, cx + half_w, cy + half_d)
def _greedy_place(
parcel: Parcel,
spec: StrategySpec,
coverage_cap: float,
) -> list[Polygon]:
"""Жадно разложить секции ``spec`` по сетке участка. Возвращает footprints (метры).
Алгоритм:
* кандидат-якоря — центры ячеек сетки в фиксированном порядке;
* footprint строится центрированно на якоре;
* принимается, если целиком внутри buildable area И не нарушает разрыв ``gap_m``
с уже принятыми (проверка через STRtree, см. :class:`_Placer`);
* раскладка останавливается, когда пятно достигает ``coverage_cap`` от buildable
area (регулятор плотности по типу застройки) — это также ограничивает число
размещений и держит O(n^2)-перестройку STRtree в бюджете.
"""
buildable = parcel.buildable_m
max_built = buildable.area * coverage_cap
placer = _Placer()
half_gap = spec.gap_m / 2.0
for cell in parcel.grid:
if placer.built_area >= max_built:
break
footprint = _centered_footprint(cell.cx, cell.cy, spec.section_w, spec.section_d)
placer.try_place(footprint, buildable, half_gap)
logger.info(
"strategy=%s placed %d sections (%.0fx%.0f m, gap=%.0f m, coverage<=%.0f%%)",
spec.name,
len(placer.placed),
spec.section_w,
spec.section_d,
spec.gap_m,
coverage_cap * 100,
)
return placer.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}
# ── Stage 3a (#1965): program-driven placement (типовые дома вместо max-FAR sweep) ──
# Разрыв между секциями в program-режиме (м). Пункт программы не несёт gap (контракт —
# только тип/этажность/количество), поэтому берём один нормативный противопожарный/
# инсоляционный зазор для всех секций программы — середина диапазона стратегий 1b
# (max_area gap=6 … max_insolation gap=15). Достаточно консервативно для реалистичной
# раскладки, не патологически разрежено.
_PROGRAM_GAP_M: float = 10.0
@dataclass(frozen=True)
class _PlacedSection:
"""Одна размещённая секция программы: пятно + этажность + тип каталога (для GeoJSON)."""
footprint: Polygon
floors: int
section_type: str
@dataclass(frozen=True)
class PlacedProgram:
"""Результат program-раскладки: что легло + честный счётчик «N из M».
``sections`` — размещённые секции (пятна + этажность + тип) в порядке программы.
``requested_count`` — сколько секций просили (Σ count по программе).
``placed_count`` — сколько реально влезло (== len(sections)). placed < requested →
участок мал, разместилось N из M (без hard-422 — честный сигнал для Stage 3b).
"""
sections: tuple[_PlacedSection, ...]
requested_count: int
placed_count: int
def place_program(
parcel: Parcel,
program: list[BuildingProgramItem],
*,
gap_m: float = _PROGRAM_GAP_M,
) -> PlacedProgram:
"""Stage 3a: разложить РОВНО заданную программу типовых домов на участок.
Для каждого пункта программы (``section_type`` из каталога × ``count`` секций) кладём
до ``count`` секций каталожного пятна на сетку участка, переиспользуя ТУ ЖЕ
collision/STRtree/setback-машину, что и жадная раскладка (:class:`_Placer`) — никакого
coverage-cap, стоп-критерий = достигнут ``count`` для пункта или кончились свободные
якоря. Пункты обрабатываются по порядку; накопленные секции участвуют в проверке
разрыва для последующих (общий :class:`_Placer`).
Если участок не вмещает все запрошенные секции, НЕ роняем 422 — кладём сколько влезло
и возвращаем честный ``placed_count``/``requested_count`` (разместилось N из M).
Raises:
KeyError: ``section_type`` пункта нет в каталоге (валидируется на API-слое до
размещения; здесь это программная ошибка контракта, не пользовательский ввод).
"""
buildable = parcel.buildable_m
half_gap = gap_m / 2.0
placer = _Placer()
placed_sections: list[_PlacedSection] = []
requested = 0
for item in program:
house = catalog.get_house_type(item.section_type)
requested += item.count
# Ручное пятно (Stage 3c): если пользователь задал ОБА габарита — кладём
# его вместо каталожного («вписать пятно»). Частичное задание игнорируем
# (нужны и ширина, и глубина), падая обратно на каталог.
if item.footprint_w_m is not None and item.footprint_d_m is not None:
fp_w, fp_d = item.footprint_w_m, item.footprint_d_m
else:
fp_w, fp_d = house.footprint_w_m, house.footprint_d_m
placed_for_item = 0
for cell in parcel.grid:
if placed_for_item >= item.count:
break
footprint = _centered_footprint(cell.cx, cell.cy, fp_w, fp_d)
if placer.try_place(footprint, buildable, half_gap):
placed_sections.append(
_PlacedSection(
footprint=footprint,
floors=item.floors,
section_type=item.section_type,
)
)
placed_for_item += 1
if placed_for_item < item.count:
logger.warning(
"program: type=%s placed %d of %d sections (%.0fx%.0f m) — участок мал",
item.section_type,
placed_for_item,
item.count,
house.footprint_w_m,
house.footprint_d_m,
)
result = PlacedProgram(
sections=tuple(placed_sections),
requested_count=requested,
placed_count=len(placed_sections),
)
logger.info(
"program placed %d of %d sections across %d type(s)",
result.placed_count,
result.requested_count,
len(program),
)
return result
def _placed_program_to_geojson(
parcel: Parcel,
sections: tuple[_PlacedSection, ...],
) -> dict[str, object]:
"""Размещённые секции программы -> WGS84 FeatureCollection (контракт buildings_geojson).
Зеркалит :func:`_footprints_to_geojson`, но каждая секция несёт СВОИ floors и тип
каталога (program-режим смешивает типы/этажности), а ``strategy`` помечается
``"program"`` — маркер, что вариант построен из программы, а не из 1b-стратегии.
"""
features: list[dict[str, object]] = []
for i, sec in enumerate(sections):
geom_wgs = parcel.metric_geom_to_wgs84(sec.footprint)
features.append(
{
"type": "Feature",
"geometry": geom_wgs,
"properties": {
"section_id": i + 1,
"floors": sec.floors,
"footprint_sqm": round(float(sec.footprint.area), 1),
"section_type": sec.section_type,
"strategy": "program",
},
}
)
return {"type": "FeatureCollection", "features": features}
def _aggregate_program_teap(
sections: tuple[_PlacedSection, ...],
*,
site_area_sqm: float,
housing_class: teap.HousingClass,
) -> TEAP:
"""Свести размещённую программу (СМЕШАННАЯ этажность) в один :class:`TEAP` — ТОЧНО.
``compute_teap`` берёт ОДНУ этажность на список пятен, поэтому при смешанной по типам
этажности нельзя просто скормить ему все пятна с одним числом (округлённая «средняя»
этажность даёт дрейф GFA ~1%). Вместо этого группируем секции по этажности, считаем
``compute_teap`` для каждой однородной группы и СУММИРУЕМ результаты:
* built / GFA / office / residential — аддитивны → сумма точна (GFA = Σ площадь_i×floors_i);
* apartments — ``Σ floor(жилая_g / avg)`` по группам: физически корректнее, чем
``floor(Σжилая / avg)`` (нельзя «склеивать» дробные квартиры между корпусами);
* parking — пересчитываем от ИТОГОВОГО числа квартир по той же норме класса (ceil от
суммы, а не сумма ceil — иначе пер-группное округление вверх задвоит места);
* density (FAR) — от суммарной GFA и площади участка (защита от деления на ноль).
Единый источник всех нормативных коэффициентов остаётся ``teap``-модуль (классовые
словари), новых магических чисел нет.
"""
if not sections:
return teap.compute_teap(
footprints=[], floors=0, site_area_sqm=site_area_sqm, housing_class=housing_class
)
# Группируем по этажности; внутри группы compute_teap корректен (одна этажность).
groups: dict[int, list[Polygon]] = {}
for sec in sections:
groups.setdefault(sec.floors, []).append(sec.footprint)
built = 0.0
gfa = 0.0
office = 0.0
residential = 0.0
apartments = 0
for floors, fps in groups.items():
# site_area_sqm здесь не важна для аддитивных полей — FAR пересчитаем в конце.
group = teap.compute_teap(
footprints=fps,
floors=floors,
site_area_sqm=site_area_sqm,
housing_class=housing_class,
)
built += group.built_area_sqm
gfa += group.total_floor_area_sqm
office += group.office_area_sqm
residential += group.residential_area_sqm
apartments += group.apartments_count
density = gfa / site_area_sqm if site_area_sqm > 0 else 0.0
parking_norm = teap._PARKING_PER_APARTMENT[housing_class]
parking_spaces = math.ceil(apartments * parking_norm)
return TEAP(
built_area_sqm=round(built, 1),
total_floor_area_sqm=round(gfa, 1),
office_area_sqm=round(office, 1),
residential_area_sqm=round(residential, 1),
apartments_count=apartments,
density=round(density, 3),
parking_spaces=parking_spaces,
)
def place_program_variant(
parcel: Parcel,
payload: ConceptInput,
*,
market_price_per_sqm: float | None = None,
price_source: str = "class_norm",
) -> ConceptVariant | None:
"""Stage 3a: построить ОДИН вариант из ``payload.building_program`` (типовые дома).
Раскладывает программу (:func:`place_program`), сводит размещённые пятна в ТЭП
(:func:`_aggregate_program_teap` — точная GFA по группам этажности) и финмодель, и
наклеивает честный сигнал частичного размещения (``placed_count``/``requested_count``).
Возвращает ``None``, если ни одна секция не легла (участок не вмещает даже одну секцию
программы) — вызывающий отбракует, как и в жадном пути.
``payload.building_program`` ДОЛЖЕН быть задан (вызывается только из program-ветки).
"""
program = payload.building_program
if not program: # защитный инвариант: эту ветку зовут только при заданной программе
raise ValueError("place_program_variant called without building_program")
placed = place_program(parcel, program)
if placed.placed_count == 0:
logger.warning(
"program placed 0 of %d sections — участок не вмещает программу, отбраковка",
placed.requested_count,
)
return None
teap_result = _aggregate_program_teap(
placed.sections,
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,
market_price_per_sqm=market_price_per_sqm,
price_source=price_source,
development_type=payload.development_type,
)
buildings_geojson = _placed_program_to_geojson(parcel, placed.sections)
# Program-вариант репортуется под "balanced" (контракт strategy — фиксированный
# Literal трёх стратегий 1b; program-режим не вводит новую стратегию, маркер режима
# лежит в properties.strategy="program" каждой фичи GeoJSON). Один вариант на программу.
return ConceptVariant(
strategy="balanced",
buildings_geojson=buildings_geojson,
teap=teap_result,
financial=financial_result,
placed_count=placed.placed_count,
requested_count=placed.requested_count,
)
def place_strategy(
parcel: Parcel,
payload: ConceptInput,
spec: StrategySpec,
*,
market_price_per_sqm: float | None = None,
price_source: str = "class_norm",
) -> ConceptVariant | None:
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant.
Возвращает ``None``, если ни одна секция не легла в пятно застройки (узкий/мелкий
участок, footprint стратегии целиком не помещается). Без этого вырожденный вариант
с нулевым размещением (revenue=0, margin=-land, IRR<0) выдавался бы как валидный —
ложь в отчёте. Отбраковку делает вызывающий :func:`place_all_strategies`.
``market_price_per_sqm`` / ``price_source`` прокидываются в :func:`compute_financial`
для калибровки цены продажи жилья по рынку (PR-2). Lookup делает API-слой; здесь
только проброс. ``None`` → норматив класса.
"""
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)
if not footprints:
logger.warning(
"strategy=%s placed 0 sections (footprint %.0fx%.0f m not buildable) — отбраковка",
spec.name,
spec.section_w,
spec.section_d,
)
return None
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,
market_price_per_sqm=market_price_per_sqm,
price_source=price_source,
# development_type задаёт длительность СМР в DCF-графике финмодели (PR-3).
development_type=payload.development_type,
)
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,
*,
market_price_per_sqm: float | None = None,
price_source: str = "class_norm",
) -> list[ConceptVariant]:
"""Stage 1b entry: построить три варианта (max_area / max_insolation / balanced).
Вырожденные стратегии (нулевое размещение) отбраковываются — в результат попадают
только варианты с реальными секциями. Если ни одна стратегия не легла (участок не
вмещает даже самую компактную секцию), это вырожденный участок: поднимаем
:class:`ParcelGeometryError` (API мапит в 422) — лучше отказ, чем пустой/лживый ответ.
``market_price_per_sqm`` / ``price_source`` (рыночная калибровка цены жилья, PR-2)
прокидываются неизменными в каждую стратегию — цена едина для участка.
Stage 3a (#1965): если задана ``payload.building_program`` — раскладываем РОВНО эту
программу типовых домов (:func:`place_program_variant`, один вариант), а НЕ три жадные
стратегии. ``building_program is None`` → существующий жадный путь без изменений.
"""
if payload.building_program:
program_variant = place_program_variant(
parcel,
payload,
market_price_per_sqm=market_price_per_sqm,
price_source=price_source,
)
if program_variant is None:
raise ParcelGeometryError(
"программа застройки не вместила ни одной секции — "
"участок слишком узкий/мелкий для выбранных типов домов"
)
logger.info(
"placed program variant: %d of %d sections, %dкв",
program_variant.placed_count,
program_variant.requested_count,
program_variant.teap.apartments_count,
)
return [program_variant]
variants = [
variant
for spec in _STRATEGIES
if (
variant := place_strategy(
parcel,
payload,
spec,
market_price_per_sqm=market_price_per_sqm,
price_source=price_source,
)
)
is not None
]
if not variants:
raise ParcelGeometryError(
"ни одна стратегия размещения не вместила секцию — участок слишком узкий/мелкий"
)
logger.info(
"placed all strategies: %s",
", ".join(f"{v.strategy}={v.teap.apartments_count}кв" for v in variants),
)
return variants
__all__ = [
"FLOOR_HEIGHT_M",
"PlacedProgram",
"StrategySpec",
"place_all_strategies",
"place_program",
"place_program_variant",
"place_strategy",
]