Some checks failed
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (push) Successful in 1m45s
CI / openapi-codegen-check (pull_request) Successful in 1m43s
CI / backend-tests (push) Failing after 8m36s
CI / backend-tests (pull_request) Failing after 8m36s
Имплементация фиксов 2-го аудита backend/app/** (после merge #1543). Воркер на файл, точечные правки. Верификация: py_compile 58/58 .py. Полностью исправлено (82). Оставлены открытыми (13): partial/needs-cross-file/needs-leha — #1569, #1590, #1593, #1606, #1609, #1617, #1633, #1635, #1637, #1638, #1640, #1642, #1650. Closes #1560 Closes #1561 Closes #1562 Closes #1563 Closes #1564 Closes #1565 Closes #1566 Closes #1567 Closes #1570 Closes #1571 Closes #1572 Closes #1573 Closes #1574 Closes #1576 Closes #1577 Closes #1578 Closes #1579 Closes #1580 Closes #1581 Closes #1582 Closes #1583 Closes #1584 Closes #1585 Closes #1586 Closes #1587 Closes #1588 Closes #1589 Closes #1591 Closes #1592 Closes #1594 Closes #1595 Closes #1596 Closes #1597 Closes #1598 Closes #1599 Closes #1600 Closes #1601 Closes #1602 Closes #1603 Closes #1604 Closes #1605 Closes #1607 Closes #1608 Closes #1610 Closes #1611 Closes #1612 Closes #1613 Closes #1614 Closes #1615 Closes #1616 Closes #1618 Closes #1619 Closes #1620 Closes #1621 Closes #1622 Closes #1623 Closes #1624 Closes #1625 Closes #1626 Closes #1627 Closes #1628 Closes #1629 Closes #1630 Closes #1631 Closes #1632 Closes #1634 Closes #1636 Closes #1639 Closes #1641 Closes #1643 Closes #1644 Closes #1645 Closes #1646 Closes #1647 Closes #1648 Closes #1649 Closes #1651 Closes #1652 Closes #1653 Closes #1654 Closes #1655 Closes #1656
265 lines
12 KiB
Python
265 lines
12 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, 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))
|
||
|
||
|
||
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 | None:
|
||
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant.
|
||
|
||
Возвращает ``None``, если ни одна секция не легла в пятно застройки (узкий/мелкий
|
||
участок, footprint стратегии целиком не помещается). Без этого вырожденный вариант
|
||
с нулевым размещением (revenue=0, margin=-land, IRR<0) выдавался бы как валидный —
|
||
ложь в отчёте. Отбраковку делает вызывающий :func:`place_all_strategies`.
|
||
"""
|
||
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,
|
||
)
|
||
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).
|
||
|
||
Вырожденные стратегии (нулевое размещение) отбраковываются — в результат попадают
|
||
только варианты с реальными секциями. Если ни одна стратегия не легла (участок не
|
||
вмещает даже самую компактную секцию), это вырожденный участок: поднимаем
|
||
:class:`ParcelGeometryError` (API мапит в 422) — лучше отказ, чем пустой/лживый ответ.
|
||
"""
|
||
variants = [
|
||
variant
|
||
for spec in _STRATEGIES
|
||
if (variant := place_strategy(parcel, payload, spec)) 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",
|
||
"StrategySpec",
|
||
"place_all_strategies",
|
||
"place_strategy",
|
||
]
|