605 lines
28 KiB
Python
605 lines
28 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
|
||
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",
|
||
]
|