feat(generative): движок концепций Stage 1a/1b/1c (#54 #55 #56)
Some checks failed
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / backend-tests (push) Has been cancelled

Заменяет 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
This commit is contained in:
Light1YT 2026-06-13 21:32:32 +05:00
parent d737307022
commit 3b79533f9f
8 changed files with 778 additions and 44 deletions

View file

@ -1,7 +1,12 @@
from fastapi import APIRouter
import logging
from fastapi import APIRouter, HTTPException
from app.schemas.concept import ConceptInput, ConceptOutput
from app.services.generative import geometry
from app.services.generative.geometry import ParcelGeometryError
logger = logging.getLogger(__name__)
router = APIRouter()
@ -10,9 +15,16 @@ router = APIRouter()
async def create_concept(payload: ConceptInput) -> ConceptOutput:
"""Generate 3 building variants for the given parcel polygon.
Stage 1a: returns stub with 3 empty variants.
Stage 1b: real greedy placement.
Stage 1c: TEAP + financial model attached.
Stage 1a: Shapely parse + buildable area (setback) + placement grid.
Stage 1b: greedy section placement with STRtree collisions (3 strategies).
Stage 1c: real ТЭП + financial model attached to each variant.
A degenerate parcel (setback consumes everything, malformed geometry) yields a
422 rather than empty variants that is a bad request, not a valid empty result.
"""
variants = geometry.generate_stub(payload)
try:
variants = geometry.generate(payload)
except ParcelGeometryError as exc:
logger.warning("concept generation rejected parcel: %s", exc)
raise HTTPException(status_code=422, detail=str(exc)) from exc
return ConceptOutput(variants=variants)

View file

@ -1,3 +1,3 @@
from app.services.generative import financial, geometry, teap
from app.services.generative import exporters, financial, geometry, placement, teap
__all__ = ["financial", "geometry", "teap"]
__all__ = ["exporters", "financial", "geometry", "placement", "teap"]

View file

@ -1,8 +1,97 @@
"""Financial model.
"""Generative Design — Stage 1c: simplified financial model.
Stage 1c:
revenue = residential_area_sqm * neighborhood_price_per_sqm
cost = total_floor_area_sqm * construction_cost_per_sqm + land_cost
gross_margin = revenue - cost
base_irr = simplified, no time discounting (defer to Phase 1).
From the Stage 1c ``TEAP`` block we derive the ``FinancialModel`` contract:
* ``revenue_rub`` = residential_area_sqm * sale price per sqm (by housing class).
* ``cost_rub`` = total_floor_area_sqm * construction cost per sqm + land cost.
* ``gross_margin_rub`` = revenue - cost.
* ``irr`` = simplified proxy (margin-on-cost / project years), NO time
discounting this is a static stand-in until the Phase 1 cashflow model lands.
Prices/costs are coarse RU-market proxies for an MVP (см. константы ниже); they are
deliberately conservative round numbers, not a calibrated pricing engine. The IRR
field is a *proxy*: a real internal rate of return needs a dated cashflow series,
which is out of MVP scope we return an annualised margin ratio so the field is
populated with a plausible, monotonic number rather than zero.
Детерминированно, без LLM / внешних API / БД.
"""
from __future__ import annotations
import logging
from typing import Literal
from app.schemas.concept import TEAP, FinancialModel
logger = logging.getLogger(__name__)
HousingClass = Literal["econom", "comfort", "business"]
# ── Цена продажи жилья, руб/кв.м (proxy рынка ЕКБ/региона, упрощённо) ──────────
_SALE_PRICE_PER_SQM: dict[HousingClass, float] = {
"econom": 110_000.0,
"comfort": 145_000.0,
"business": 210_000.0,
}
# ── Себестоимость СМР, руб/кв.м общей площади (выше класс -> дороже отделка/инж) ─
_CONSTRUCTION_COST_PER_SQM: dict[HousingClass, float] = {
"econom": 72_000.0,
"comfort": 88_000.0,
"business": 120_000.0,
}
# Условный горизонт проекта (лет) для аннуализации margin-on-cost в IRR-proxy.
_PROJECT_YEARS: float = 3.0
def compute_financial(
*,
teap: TEAP,
housing_class: HousingClass,
land_cost_rub: float | None,
) -> FinancialModel:
"""Свести ТЭП + класс + стоимость земли в :class:`FinancialModel`.
Args:
teap: Stage 1c ТЭП (берём residential_area_sqm и total_floor_area_sqm).
housing_class: задаёт цену продажи и себестоимость СМР.
land_cost_rub: стоимость участка (опционально); None -> 0 в затратах.
"""
sale_price = _SALE_PRICE_PER_SQM[housing_class]
construction_cost = _CONSTRUCTION_COST_PER_SQM[housing_class]
revenue = teap.residential_area_sqm * sale_price
construction = teap.total_floor_area_sqm * construction_cost
land = land_cost_rub if land_cost_rub is not None else 0.0
cost = construction + land
gross_margin = revenue - cost
# IRR-proxy: аннуализированная маржа-на-затраты. НЕ настоящий IRR (нет дисконта/
# дат денежных потоков — отложено в Phase 1). Защита от деления на ноль и
# клампинг в разумный диапазон, чтобы поле было монотонным и читаемым.
if cost > 0:
margin_on_cost = gross_margin / cost
irr = margin_on_cost / _PROJECT_YEARS
else:
irr = 0.0
irr = max(-1.0, min(1.0, irr))
model = FinancialModel(
revenue_rub=round(revenue, 2),
cost_rub=round(cost, 2),
gross_margin_rub=round(gross_margin, 2),
irr=round(irr, 4),
)
logger.info(
"financial: revenue=%.0f cost=%.0f margin=%.0f irr_proxy=%.3f",
model.revenue_rub,
model.cost_rub,
model.gross_margin_rub,
model.irr,
)
return model
__all__ = ["HousingClass", "compute_financial"]

View file

@ -1,35 +1,312 @@
"""Generative Design — geometry placement.
"""Generative Design — Stage 1a geometry: parcel parsing + buildable area + grid.
Stage 1a: Shapely-based polygon parsing + normative offsets (buffer).
Stage 1b: greedy filling of rectangular MKD with 3 strategies. STRtree for collisions.
Performance target: <=10s per variant; fallback acceptance 15s.
Pipeline (deterministic, no LLM / no external API / no DB):
1. Parse the parcel polygon from ``ConceptInput.parcel_geojson`` (GeoJSON Polygon,
WGS84 / EPSG:4326) into a Shapely geometry.
2. Reproject WGS84 -> a local *metric* CRS (an azimuthal-equidistant projection
centred on the parcel centroid) so that all downstream maths is in metres.
We deliberately avoid UTM zone math: an AEQD centred on the parcel is accurate
to well within construction tolerance for parcels of city-block size and is
fully deterministic for any longitude/latitude.
3. Apply the normative setback (отступ) as an *inward* buffer -> the buildable area
(участок минус отступы).
4. Lay a deterministic placement grid of candidate cells over the buildable area's
bounding box; a cell is kept when its centre falls inside the buildable area.
The metric geometry + the WGS84<->metric transformers are bundled in :class:`Parcel`
so Stage 1b (placement) can do collision maths in metres and reproject the result
back to WGS84 for the ``ConceptVariant.buildings_geojson`` contract field.
``generate()`` (bottom of file) is the public orchestrator that the API calls; it
ties together Stage 1a -> 1b -> 1c. ``generate_stub`` is kept as a thin alias so the
existing route import keeps working.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from app.schemas.concept import TEAP, ConceptInput, ConceptVariant, FinancialModel
from pyproj import CRS, Transformer
from shapely.geometry import Polygon, mapping, shape
from shapely.geometry.base import BaseGeometry
from shapely.ops import transform as shapely_transform
from app.schemas.concept import ConceptInput, ConceptVariant
logger = logging.getLogger(__name__)
# ── Normative defaults (упрощённо для MVP) ────────────────────────────────────
# Setback (отступ от границ участка до пятна застройки), метры. СП 42.13330 даёт
# ~3 м минимум от боковых границ; берём 6 м как консервативный proxy с учётом проездов.
DEFAULT_SETBACK_M: float = 6.0
# Шаг сетки размещения (метры). 3 м — компромисс: достаточно мелкий, чтобы жадная
# раскладка реально различала стратегии по разрыву (gap), и достаточно крупный,
# чтобы число ячеек/время оставались ограниченными на квартальном участке.
DEFAULT_GRID_STEP_M: float = 3.0
# Минимальная площадь buildable area (кв.м), ниже которой застройка не имеет смысла.
MIN_BUILDABLE_AREA_SQM: float = 50.0
# Потолок числа ячеек сетки. Жадная раскладка с перестройкой STRtree ~O(n^2) по числу
# размещённых секций; для огромного участка шаг автоматически огрубляется, чтобы
# удержать время в бюджете (<=10 c/вариант). MVP-упрощение.
MAX_GRID_CELLS: int = 20_000
# WGS84 (вход контракта).
_WGS84 = CRS.from_epsg(4326)
class ParcelGeometryError(ValueError):
"""Входной полигон участка невалиден (не Polygon / вырожден / пустой)."""
@dataclass(frozen=True)
class GridCell:
"""Одна ячейка сетки размещения (метрическая СК, центр + габариты)."""
cx: float
cy: float
width: float
height: float
def as_polygon(self) -> Polygon:
"""Прямоугольник ячейки как Shapely-полигон (метры)."""
half_w = self.width / 2.0
half_h = self.height / 2.0
return Polygon(
[
(self.cx - half_w, self.cy - half_h),
(self.cx + half_w, self.cy - half_h),
(self.cx + half_w, self.cy + half_h),
(self.cx - half_w, self.cy + half_h),
]
)
@dataclass(frozen=True)
class Parcel:
"""Распарсенный участок в метрической СК + данные для Stage 1b/1c.
``polygon_m`` / ``buildable_m`` геометрия в метрах (AEQD вокруг центроида).
``metric_geom_to_wgs84`` репроецирует метрику обратно в WGS84 для GeoJSON-ответа.
"""
polygon_m: Polygon
buildable_m: Polygon
grid: tuple[GridCell, ...]
grid_step_m: float
setback_m: float
_to_wgs84: Transformer
_to_metric: Transformer
@property
def site_area_sqm(self) -> float:
"""Площадь участка, кв.м."""
return float(self.polygon_m.area)
@property
def buildable_area_sqm(self) -> float:
"""Площадь пятна застройки (участок минус отступы), кв.м."""
return float(self.buildable_m.area)
def metric_geom_to_wgs84(self, geom: BaseGeometry) -> dict[str, Any]:
"""Репроекция метрической геометрии обратно в WGS84 -> GeoJSON-mapping."""
wgs = shapely_transform(self._reproject, geom)
return dict(mapping(wgs))
def wgs84_to_metric(self, lon: float, lat: float) -> tuple[float, float]:
"""Одна точка WGS84 (lon, lat) -> метрическая (x, y) в СК участка."""
x, y = self._to_metric.transform(lon, lat)
return float(x), float(y)
def _reproject(self, xs: Any, ys: Any) -> tuple[Any, Any]:
lon, lat = self._to_wgs84.transform(xs, ys)
return lon, lat
def _parse_polygon(parcel_geojson: dict[str, Any]) -> Polygon:
"""GeoJSON -> Shapely Polygon. Принимает голую geometry ИЛИ Feature."""
if not isinstance(parcel_geojson, dict):
raise ParcelGeometryError("parcel_geojson must be a GeoJSON object")
geom_dict: dict[str, Any] = parcel_geojson
if parcel_geojson.get("type") == "Feature":
geometry = parcel_geojson.get("geometry")
if not isinstance(geometry, dict):
raise ParcelGeometryError("Feature has no geometry")
geom_dict = geometry
try:
geom = shape(geom_dict)
except (KeyError, TypeError, ValueError, AttributeError) as exc:
raise ParcelGeometryError(f"cannot parse GeoJSON geometry: {exc}") from exc
if geom.geom_type != "Polygon":
raise ParcelGeometryError(f"expected Polygon, got {geom.geom_type}")
if geom.is_empty:
raise ParcelGeometryError("parcel polygon is empty")
polygon = geom if isinstance(geom, Polygon) else Polygon(geom)
if not polygon.is_valid:
# buffer(0) — канонический Shapely-фикс самопересечений/неориентированных колец.
fixed = polygon.buffer(0)
if fixed.is_empty or fixed.geom_type != "Polygon":
raise ParcelGeometryError("parcel polygon is not a valid simple polygon")
polygon = fixed if isinstance(fixed, Polygon) else Polygon(fixed)
return polygon
def _metric_transformers(polygon_wgs84: Polygon) -> tuple[Transformer, Transformer]:
"""Построить пару трансформеров WGS84<->метрический AEQD вокруг центроида участка.
AEQD (azimuthal equidistant) центрированный на участке детерминирован для любых
координат и точен на масштабе квартала не нужен выбор UTM-зоны.
"""
centroid = polygon_wgs84.centroid
metric_crs = CRS.from_proj4(
f"+proj=aeqd +lat_0={centroid.y} +lon_0={centroid.x} "
"+x_0=0 +y_0=0 +ellps=WGS84 +datum=WGS84 +units=m +no_defs"
)
to_metric = Transformer.from_crs(_WGS84, metric_crs, always_xy=True)
to_wgs84 = Transformer.from_crs(metric_crs, _WGS84, always_xy=True)
return to_metric, to_wgs84
def build_placement_grid(buildable_m: Polygon, step_m: float) -> tuple[GridCell, ...]:
"""Детерминированная сетка ячеек ``step_m x step_m`` над пятном застройки.
Ячейка попадает в сетку, если её центр лежит внутри ``buildable_m``. Перебор
идёт по фиксированному порядку (снизу-вверх, слева-направо) от округлённого
минимального угла bbox -> один и тот же вход даёт один и тот же выход.
"""
if buildable_m.is_empty or step_m <= 0:
return ()
minx, miny, maxx, maxy = buildable_m.bounds
# Якорим старт сетки к кратному step, чтобы убрать дрейф от плавающего bbox.
start_x = (minx // step_m) * step_m
start_y = (miny // step_m) * step_m
cells: list[GridCell] = []
n_cols = int((maxx - start_x) / step_m) + 1
n_rows = int((maxy - start_y) / step_m) + 1
for row in range(n_rows):
cy = start_y + (row + 0.5) * step_m
for col in range(n_cols):
cx = start_x + (col + 0.5) * step_m
cell = GridCell(cx=cx, cy=cy, width=step_m, height=step_m)
# Центр внутри пятна — ячейка пригодна (covers ловит и границу).
if buildable_m.covers(cell.as_polygon().centroid):
cells.append(cell)
return tuple(cells)
def _coarsen_step_for_budget(buildable_m: Polygon, step_m: float) -> float:
"""Огрубить шаг сетки, если bbox-оценка ячеек превышает :data:`MAX_GRID_CELLS`.
Грубая оценка по bbox (верхняя граница реального числа ячеек). Возвращает шаг,
при котором оценка <= cap; детерминированно. MVP-страховка от взрыва времени на
гигантских участках обычный квартал её не задевает.
"""
minx, miny, maxx, maxy = buildable_m.bounds
width = float(maxx) - float(minx)
height = float(maxy) - float(miny)
if width <= 0 or height <= 0 or step_m <= 0:
return step_m
est_cells = (width / step_m) * (height / step_m)
if est_cells <= MAX_GRID_CELLS:
return step_m
# step растёт как sqrt(est/cap), чтобы число ячеек ~= cap.
factor: float = (est_cells / MAX_GRID_CELLS) ** 0.5
coarsened: float = step_m * factor
logger.warning(
"buildable bbox %.0fx%.0f m: grid step coarsened %.1f->%.1f m to cap cells at %d",
width,
height,
step_m,
coarsened,
MAX_GRID_CELLS,
)
return coarsened
def parse_parcel(
payload: ConceptInput,
*,
setback_m: float = DEFAULT_SETBACK_M,
grid_step_m: float = DEFAULT_GRID_STEP_M,
) -> Parcel:
"""Stage 1a: ConceptInput -> :class:`Parcel` (метрика + buildable + grid).
Raises:
ParcelGeometryError: полигон невалиден или пятно застройки вырождается.
"""
polygon_wgs84 = _parse_polygon(payload.parcel_geojson)
to_metric, to_wgs84 = _metric_transformers(polygon_wgs84)
def _fwd(xs: Any, ys: Any) -> tuple[Any, Any]:
x, y = to_metric.transform(xs, ys)
return x, y
polygon_m = shapely_transform(_fwd, polygon_wgs84)
if not isinstance(polygon_m, Polygon):
raise ParcelGeometryError("reprojected parcel is not a polygon")
# Отступ внутрь: отрицательный буфер. join_style=mitre держит прямые углы.
buildable = polygon_m.buffer(-setback_m, join_style="mitre")
if buildable.is_empty:
raise ParcelGeometryError(
f"setback {setback_m} m consumes the whole parcel "
f"(area={polygon_m.area:.1f} sqm) — no buildable area"
)
# После буфера может остаться MultiPolygon (узкий перешеек) — берём крупнейший.
if buildable.geom_type == "MultiPolygon":
buildable = max(buildable.geoms, key=lambda g: g.area)
if not isinstance(buildable, Polygon):
raise ParcelGeometryError("buildable area degenerated after setback")
if buildable.area < MIN_BUILDABLE_AREA_SQM:
raise ParcelGeometryError(
f"buildable area {buildable.area:.1f} sqm below minimum "
f"{MIN_BUILDABLE_AREA_SQM} sqm"
)
effective_step = _coarsen_step_for_budget(buildable, grid_step_m)
grid = build_placement_grid(buildable, effective_step)
logger.info(
"parsed parcel: site=%.0f sqm buildable=%.0f sqm grid_cells=%d step=%.1fm",
polygon_m.area,
buildable.area,
len(grid),
effective_step,
)
return Parcel(
polygon_m=polygon_m,
buildable_m=buildable,
grid=grid,
grid_step_m=effective_step,
setback_m=setback_m,
_to_wgs84=to_wgs84,
_to_metric=to_metric,
)
def generate(payload: ConceptInput) -> list[ConceptVariant]:
"""Public orchestrator: Stage 1a -> 1b -> 1c -> 3 filled :class:`ConceptVariant`.
Deterministic end-to-end. On a degenerate parcel (setback eats everything, bad
geometry) we *log and re-raise* :class:`ParcelGeometryError` the API layer maps
it to 4xx; silently returning zero-variants would hide a bad request.
"""
# Local import to avoid a module-level import cycle (placement imports geometry).
from app.services.generative import placement
parcel = parse_parcel(payload)
variants = placement.place_all_strategies(parcel, payload)
logger.info("generated %d concept variants", len(variants))
return variants
def generate_stub(payload: ConceptInput) -> list[ConceptVariant]:
"""Placeholder returning 3 empty variants. Replaced in Stage 1b."""
empty_buildings: dict[str, Any] = {"type": "FeatureCollection", "features": []}
empty_teap = TEAP(
built_area_sqm=0.0,
total_floor_area_sqm=0.0,
residential_area_sqm=0.0,
apartments_count=0,
density=0.0,
parking_spaces=0,
)
empty_financial = FinancialModel(revenue_rub=0.0, cost_rub=0.0, gross_margin_rub=0.0, irr=0.0)
strategies: list[ConceptVariant] = []
for strategy in ("max_area", "max_insolation", "balanced"):
strategies.append(
ConceptVariant(
strategy=strategy,
buildings_geojson=empty_buildings,
teap=empty_teap,
financial=empty_financial,
)
)
return strategies
"""Backwards-compatible alias. Now delegates to the real :func:`generate`."""
return generate(payload)

View file

@ -0,0 +1,237 @@
"""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",
]

View file

@ -1,5 +1,106 @@
"""TEAP (technical-economic indicators) calculations.
"""Generative Design — Stage 1c: ТЭП (technical-economic indicators).
Stage 1c: apartment count by ratios (1/2/3-room), KEP (land use coefficient),
density, parking by simplified norm.
From the Stage 1b placement (rectangular section footprints + floor count) we derive
the ``TEAP`` contract block with *real* numbers:
* ``built_area_sqm`` пятно застройки (сумма площадей footprint-ов).
* ``total_floor_area_sqm`` общая поэтажная площадь (GFA) = пятно * этажность.
* ``residential_area_sqm`` продаваемая жилая = GFA * коэффициент эффективности
(вычет МОП/лестниц/тех.помещений; зависит от класса жилья).
* ``apartments_count`` жилая / средняя площадь квартиры (зависит от класса).
* ``density`` плотность застройки = FAR = GFA / площадь участка.
* ``parking_spaces`` машиноместа по упрощённой норме (мест на квартиру).
Все коэффициенты упрощённые нормативные proxy для MVP (см. константы ниже).
Детерминированно, без LLM / внешних API / БД.
"""
from __future__ import annotations
import logging
import math
from typing import Literal
from shapely.geometry import Polygon
from app.schemas.concept import TEAP
logger = logging.getLogger(__name__)
HousingClass = Literal["econom", "comfort", "business"]
# ── Коэффициент эффективности площади (residential / GFA), доля ────────────────
# Доля продаваемой жилой в общей поэтажной (остальное — МОП, лестницы, тех.этаж).
# Бизнес-класс «тратит» больше на МОП/лобби -> ниже эффективность.
_EFFICIENCY_BY_CLASS: dict[HousingClass, float] = {
"econom": 0.82,
"comfort": 0.78,
"business": 0.72,
}
# ── Средняя площадь квартиры (кв.м) по классу — выше класс -> крупнее лот ──────
_AVG_APARTMENT_SQM: dict[HousingClass, float] = {
"econom": 42.0,
"comfort": 55.0,
"business": 78.0,
}
# ── Норма парковки (машиномест на квартиру) по классу ─────────────────────────
_PARKING_PER_APARTMENT: dict[HousingClass, float] = {
"econom": 0.8,
"comfort": 1.0,
"business": 1.5,
}
def compute_teap(
*,
footprints: list[Polygon],
floors: int,
site_area_sqm: float,
housing_class: HousingClass,
) -> TEAP:
"""Свести footprints + этажность в :class:`TEAP`.
Args:
footprints: метрические пятна секций (кв.м берётся из ``.area``).
floors: этажность (общая для всех секций варианта).
site_area_sqm: площадь участка для плотности (FAR).
housing_class: класс жилья задаёт эффективность/средний лот/парковку.
"""
built_area = float(sum(fp.area for fp in footprints))
total_floor_area = built_area * max(0, floors)
efficiency = _EFFICIENCY_BY_CLASS[housing_class]
residential_area = total_floor_area * efficiency
avg_apartment = _AVG_APARTMENT_SQM[housing_class]
apartments_count = math.floor(residential_area / avg_apartment) if avg_apartment else 0
# Плотность застройки = FAR (GFA / площадь участка). Защита от деления на ноль.
density = total_floor_area / site_area_sqm if site_area_sqm > 0 else 0.0
parking_norm = _PARKING_PER_APARTMENT[housing_class]
parking_spaces = math.ceil(apartments_count * parking_norm)
teap = TEAP(
built_area_sqm=round(built_area, 1),
total_floor_area_sqm=round(total_floor_area, 1),
residential_area_sqm=round(residential_area, 1),
apartments_count=apartments_count,
density=round(density, 3),
parking_spaces=parking_spaces,
)
logger.info(
"TEAP: built=%.0f GFA=%.0f resid=%.0f apts=%d FAR=%.2f parking=%d",
teap.built_area_sqm,
teap.total_floor_area_sqm,
teap.residential_area_sqm,
teap.apartments_count,
teap.density,
teap.parking_spaces,
)
return teap
__all__ = ["HousingClass", "compute_teap"]

View file

@ -79,12 +79,25 @@ warn_unused_ignores = true
[[tool.mypy.overrides]]
module = [
"app.services.generative.geometry",
"app.services.generative.placement",
"app.services.generative.teap",
"app.services.generative.financial",
"app.services.generative.exporters.dxf",
"app.services.generative.exporters.pdf",
"app.services.site_finder.scorer",
]
strict = true
# Геометрия/экспорт-библиотеки без type stubs (shapely/ezdxf/weasyprint не несут
# py.typed) — игнорируем missing-imports, чтобы strict-модули generative проходили.
[[tool.mypy.overrides]]
module = [
"shapely.*",
"ezdxf.*",
"weasyprint.*",
]
ignore_missing_imports = true
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"

View file

@ -1,4 +1,9 @@
"""Smoke test for the Concept stub. Real algorithm tests come in Stage 1b."""
"""Smoke test for the Concept endpoint shape (3 strategies present).
The stub is now replaced by the real Stage 1a/1b/1c pipeline; richer assertions on
filled ТЭП/financial live in tests/services/generative/test_api_concepts.py. This
file is kept as a minimal endpoint-shape smoke.
"""
from fastapi.testclient import TestClient