"""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 placed_for_item = 0 for cell in parcel.grid: if placed_for_item >= item.count: break footprint = _centered_footprint( cell.cx, cell.cy, house.footprint_w_m, house.footprint_d_m ) 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", ]