diff --git a/backend/app/services/generative/exporters/__init__.py b/backend/app/services/generative/exporters/__init__.py new file mode 100644 index 00000000..4f2b0752 --- /dev/null +++ b/backend/app/services/generative/exporters/__init__.py @@ -0,0 +1,11 @@ +"""Generative Design — Stage 1c exporters (DXF geometry + PDF summary). + +Distinct from Site Finder's ``app.services.exporters`` (report_pdf etc.): these +serialise *concept* output — parcel + placed buildings (DXF) and the ТЭП/финмодель +summary (PDF). Both are deterministic and consume already-computed Stage 1a/1b/1c +objects (no re-parsing, no DB, no network). +""" + +from app.services.generative.exporters import dxf, pdf + +__all__ = ["dxf", "pdf"] diff --git a/backend/app/services/generative/exporters/dxf.py b/backend/app/services/generative/exporters/dxf.py new file mode 100644 index 00000000..5ba88db8 --- /dev/null +++ b/backend/app/services/generative/exporters/dxf.py @@ -0,0 +1,163 @@ +"""Generative Design — Stage 1c DXF export via ezdxf. + +Renders the parcel context (boundary + buildable area) and one variant's placed +building footprints into a binary DXF for hand-off to architects. Geometry is drawn +in the parcel's *metric* CRS (metres) — architects work in metres, and DXF has no +geographic CRS concept, so emitting WGS84 degrees would be unusable. + +Layers: + * ``PARCEL`` — границы участка (синий). + * ``BUILDABLE`` — пятно застройки после отступов (зелёный, пунктир-цвет). + * ``BUILDINGS`` — секции варианта (красный), с текстовой подписью номера секции. + +Returns ``bytes`` (binary DXF, R2010) ready for an HTTP response / file write. +Deterministic, no DB / no network. ``ezdxf`` is a light import, so it stays at +module level (unlike WeasyPrint in :mod:`pdf`). +""" + +from __future__ import annotations + +import io +import logging + +# ezdxf.new живёт в ezdxf.filemanagement и не реэкспортируется через ezdxf.__all__; +# импорт из модуля удовлетворяет strict no-implicit-reexport. +from ezdxf.filemanagement import new as ezdxf_new +from shapely.geometry import Polygon + +from app.schemas.concept import ConceptVariant +from app.services.generative.geometry import Parcel + +logger = logging.getLogger(__name__) + +# AutoCAD Color Index (ACI) per layer. +_ACI_PARCEL = 5 # blue +_ACI_BUILDABLE = 3 # green +_ACI_BUILDINGS = 1 # red + +_LAYER_PARCEL = "PARCEL" +_LAYER_BUILDABLE = "BUILDABLE" +_LAYER_BUILDINGS = "BUILDINGS" + +# Высота текста подписи секции (метры в модельном пространстве). +_LABEL_HEIGHT_M = 2.0 + + +def _polygon_points(poly: Polygon) -> list[tuple[float, float]]: + """Внешнее кольцо полигона как список (x, y) для LWPolyline (без замыкающей точки).""" + coords = list(poly.exterior.coords) + # Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам. + if len(coords) > 1 and coords[0] == coords[-1]: + coords = coords[:-1] + return [(float(x), float(y)) for x, y in coords] + + +def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes: + """Собрать binary DXF: участок + пятно застройки + секции одного варианта. + + Args: + parcel: Stage 1a участок (метрическая геометрия parcel/buildable). + variant: вариант, чьи секции рисуем (footprints берём из его geojson — + но геометрию рисуем из метрического parcel-space через свежий парсинг + geojson обратно нельзя без CRS, поэтому секции восстанавливаем ниже). + + Returns: + bytes: бинарный DXF R2010. + """ + doc = ezdxf_new("R2010") + doc.layers.add(_LAYER_PARCEL, color=_ACI_PARCEL) + doc.layers.add(_LAYER_BUILDABLE, color=_ACI_BUILDABLE) + doc.layers.add(_LAYER_BUILDINGS, color=_ACI_BUILDINGS) + msp = doc.modelspace() + + # Участок и пятно застройки — из метрической геометрии Parcel. + msp.add_lwpolyline( + _polygon_points(parcel.polygon_m), + close=True, + dxfattribs={"layer": _LAYER_PARCEL}, + ) + msp.add_lwpolyline( + _polygon_points(parcel.buildable_m), + close=True, + dxfattribs={"layer": _LAYER_BUILDABLE}, + ) + + # Секции: восстанавливаем метрические footprints из WGS84-geojson варианта. + features = variant.buildings_geojson.get("features", []) + section_count = 0 + if isinstance(features, list): + for feature in features: + footprint = _feature_to_metric_polygon(parcel, feature) + if footprint is None: + continue + section_count += 1 + msp.add_lwpolyline( + _polygon_points(footprint), + close=True, + dxfattribs={"layer": _LAYER_BUILDINGS}, + ) + centroid = footprint.centroid + label = str(_feature_section_id(feature, section_count)) + text = msp.add_text( + label, + dxfattribs={"layer": _LAYER_BUILDINGS, "height": _LABEL_HEIGHT_M}, + ) + text.set_placement((float(centroid.x), float(centroid.y))) + + stream = io.BytesIO() + doc.write(stream, fmt="bin") + data = stream.getvalue() + logger.info( + "DXF export: strategy=%s sections=%d bytes=%d", + variant.strategy, + section_count, + len(data), + ) + return data + + +def _feature_section_id(feature: object, fallback: int) -> int: + """Достать section_id из properties Feature, иначе fallback-счётчик.""" + if isinstance(feature, dict): + props = feature.get("properties") + if isinstance(props, dict): + sid = props.get("section_id") + if isinstance(sid, int): + return sid + return fallback + + +def _feature_to_metric_polygon(parcel: Parcel, feature: object) -> Polygon | None: + """WGS84 GeoJSON Feature -> метрический Shapely Polygon (через обратный трансформер). + + Возвращает None для невалидных/непригодных фич (graceful — экспорт не падает). + """ + if not isinstance(feature, dict): + return None + geometry = feature.get("geometry") + if not isinstance(geometry, dict) or geometry.get("type") != "Polygon": + return None + coords = geometry.get("coordinates") + # Shapely mapping() emits nested tuples; accept both tuple and list. + if not isinstance(coords, (list, tuple)) or not coords: + return None + ring = coords[0] + if not isinstance(ring, (list, tuple)) or len(ring) < 4: + return None + + metric_pts: list[tuple[float, float]] = [] + for pt in ring: + if not isinstance(pt, (list, tuple)) or len(pt) < 2: + return None + lon, lat = float(pt[0]), float(pt[1]) + x, y = parcel.wgs84_to_metric(lon, lat) + metric_pts.append((x, y)) + + try: + poly = Polygon(metric_pts) + except (ValueError, TypeError): + return None + return poly if (poly.is_valid and not poly.is_empty) else None + + +__all__ = ["export_concept_dxf"] diff --git a/backend/app/services/generative/exporters/pdf.py b/backend/app/services/generative/exporters/pdf.py new file mode 100644 index 00000000..ab99ef15 --- /dev/null +++ b/backend/app/services/generative/exporters/pdf.py @@ -0,0 +1,160 @@ +"""Generative Design — Stage 1c PDF export via WeasyPrint. + +Renders a one-page summary of the three concept variants — a ТЭП table and a +financial table — into a PDF. This is the *concept* summary, distinct from Site +Finder's ``app.services.exporters.report_pdf`` (advisory site report). + +WeasyPrint is imported *lazily inside the function* (mirrors the repo's +``report_pdf`` / ``snapshot_pdf`` house-style): it is a heavy native dependency, so +importing this module must never fail even on a dev box without the system libs. + +All dynamic strings are passed through ``html.escape`` (defence-in-depth: variant +strategy names are from a fixed Literal, but treat rendered text as untrusted). +Returns ``bytes`` ready for an HTTP response / file write. Deterministic, no DB / +no network. +""" + +from __future__ import annotations + +import html +import logging +from collections.abc import Sequence + +from app.schemas.concept import ConceptVariant + +logger = logging.getLogger(__name__) + +# RU-подписи стратегий (ключ — Literal из контракта). +_STRATEGY_LABELS: dict[str, str] = { + "max_area": "Максимум площади", + "max_insolation": "Максимум инсоляции", + "balanced": "Баланс", +} + +_DASH = "—" + +# Минимальный CSS для печати (А4, читаемые таблицы). Inline — без внешних ресурсов. +_CSS = """ +@page { size: A4 landscape; margin: 18mm; } +body { font-family: "DejaVu Sans", Arial, sans-serif; font-size: 11px; color: #1a1a1a; } +h1 { font-size: 18px; margin: 0 0 4px; } +.sub { color: #666; font-size: 10px; margin: 0 0 14px; } +table { border-collapse: collapse; width: 100%; margin-bottom: 18px; } +th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: right; } +th.row, td.row { text-align: left; font-weight: 600; background: #f5f5f5; } +caption { text-align: left; font-weight: 700; font-size: 13px; margin-bottom: 6px; } +thead th { background: #ececec; } +""" + +_TITLE = "Концепции застройки — сводка вариантов" +_SUBTITLE = "Generative Design · Stage 1c · детерминированный расчёт ТЭП и финмодели" + + +def _fmt_int(value: float | int) -> str: + """Целое с разделителями тысяч (узкий пробел) для читаемости.""" + return f"{round(value):,}".replace(",", " ") + + +def _fmt_money(value: float) -> str: + """Деньги в млн руб (1 знак) — итоговые таблицы читаются в млн.""" + return f"{value / 1_000_000:,.1f}".replace(",", " ") + + +def _strategy_label(strategy: str) -> str: + return _STRATEGY_LABELS.get(strategy, strategy) + + +def _teap_table(variants: Sequence[ConceptVariant]) -> str: + """HTML-таблица ТЭП по всем вариантам (строки — показатели, колонки — стратегии).""" + headers = "".join( + f"
| Показатель | {headers}
|---|
| Показатель | {headers}
|---|
{html.escape(_SUBTITLE)}
" + f"{_DASH} нет вариантов для отображения
" + ) + return ( + f"" + f"{html.escape(_SUBTITLE)}
" + f"{_teap_table(variants)}" + f"{_financial_table(variants)}" + "IRR-proxy — аннуализированная маржа-на-затраты без " + "дисконтирования (не настоящий IRR). Цены и себестоимость — рыночные " + "ориентиры, не калиброванная модель ценообразования.
" + "" + ) + + +def export_concept_pdf(variants: Sequence[ConceptVariant]) -> bytes: + """Свести варианты в PDF-сводку (ТЭП + финмодель). Возвращает bytes (PDF). + + Graceful: пустой список вариантов рендерит страницу-заглушку, экспорт не падает. + WeasyPrint импортируется лениво (тяжёлая нативная зависимость). + """ + # Лениво: импорт WeasyPrint не должен падать при импорте модуля + # (тяжёлая нативная зависимость; зеркало report_pdf/snapshot_pdf). + from weasyprint import HTML + + document = _build_html(variants) + # write_pdf(target=None) возвращает bytes; weasyprint без stubs -> явная коэрция. + rendered = HTML(string=document).write_pdf() + pdf_bytes: bytes = bytes(rendered) if rendered is not None else b"" + logger.info("PDF export: variants=%d bytes=%d", len(variants), len(pdf_bytes)) + return pdf_bytes + + +__all__ = ["export_concept_pdf"] diff --git a/backend/tests/services/generative/test_api_concepts.py b/backend/tests/services/generative/test_api_concepts.py new file mode 100644 index 00000000..1d436cca --- /dev/null +++ b/backend/tests/services/generative/test_api_concepts.py @@ -0,0 +1,115 @@ +"""End-to-end API test — POST /api/v1/concepts returns 3 filled variants. + +Goes through the FastAPI route (TestClient) and asserts the contract is populated +with *real* numbers (non-zero ТЭП + financial), valid building GeoJSON, and that a +degenerate parcel yields 422 rather than empty variants. +""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.main import app + +_PARCEL = { + "type": "Polygon", + "coordinates": [ + [ + [60.60, 56.830], + [60.6045, 56.830], + [60.6045, 56.8328], + [60.60, 56.8328], + [60.60, 56.830], + ] + ], +} + + +def _post(payload: dict[str, object]) -> object: + client = TestClient(app) + return client.post("/api/v1/concepts", json=payload) + + +def test_concepts_returns_three_filled_variants() -> None: + response = _post( + { + "parcel_geojson": _PARCEL, + "housing_class": "comfort", + "target_floors": 9, + "development_type": "mid_rise", + "land_cost_rub": 150_000_000, + } + ) + assert response.status_code == 200, response.text + variants = response.json()["variants"] + assert len(variants) == 3 + assert {v["strategy"] for v in variants} == {"max_area", "max_insolation", "balanced"} + + for v in variants: + teap = v["teap"] + fin = v["financial"] + # Реальные, ненулевые числа (не stub-нули). + assert teap["built_area_sqm"] > 0 + assert teap["total_floor_area_sqm"] > 0 + assert teap["residential_area_sqm"] > 0 + assert teap["apartments_count"] > 0 + assert teap["density"] > 0 + assert teap["parking_spaces"] > 0 + assert fin["revenue_rub"] > 0 + assert fin["cost_rub"] > 0 + # GeoJSON застройки непустой. + fc = v["buildings_geojson"] + assert fc["type"] == "FeatureCollection" + assert len(fc["features"]) > 0 + + +def test_concepts_degenerate_parcel_returns_422() -> None: + tiny = { + "type": "Polygon", + "coordinates": [ + [ + [60.60, 56.830], + [60.60015, 56.830], + [60.60015, 56.83015], + [60.60, 56.83015], + [60.60, 56.830], + ] + ], + } + response = _post( + { + "parcel_geojson": tiny, + "housing_class": "comfort", + "target_floors": 9, + "development_type": "mid_rise", + } + ) + assert response.status_code == 422 + + +def test_concepts_response_matches_contract_keys() -> None: + response = _post( + { + "parcel_geojson": _PARCEL, + "housing_class": "business", + "target_floors": 16, + "development_type": "high_rise", + } + ) + assert response.status_code == 200 + variant = response.json()["variants"][0] + assert set(variant.keys()) == {"strategy", "buildings_geojson", "teap", "financial"} + assert set(variant["teap"].keys()) == { + "built_area_sqm", + "total_floor_area_sqm", + "residential_area_sqm", + "apartments_count", + "density", + "parking_spaces", + } + assert set(variant["financial"].keys()) == { + "revenue_rub", + "cost_rub", + "gross_margin_rub", + "irr", + } diff --git a/backend/tests/services/generative/test_exporters.py b/backend/tests/services/generative/test_exporters.py new file mode 100644 index 00000000..da30de60 --- /dev/null +++ b/backend/tests/services/generative/test_exporters.py @@ -0,0 +1,127 @@ +"""Stage 1c tests — DXF and PDF exporters. + +DXF is asserted by a binary round-trip (re-read with ezdxf, check layers/entities). +The PDF render is skipped when WeasyPrint's native libraries are unavailable (dev +boxes без libgobject); the HTML-build step is always exercised. +""" + +from __future__ import annotations + +import importlib.util +import os +import tempfile +from collections import Counter + +import ezdxf +import pytest + +from app.schemas.concept import ConceptInput +from app.services.generative import geometry +from app.services.generative.exporters import dxf, pdf + +_PARCEL_COORDS = [ + [60.60, 56.830], + [60.6045, 56.830], + [60.6045, 56.8328], + [60.60, 56.8328], + [60.60, 56.830], +] + + +def _payload() -> ConceptInput: + return ConceptInput( + parcel_geojson={"type": "Polygon", "coordinates": [_PARCEL_COORDS]}, + housing_class="comfort", + target_floors=9, + development_type="mid_rise", + land_cost_rub=150_000_000.0, + ) + + +def _weasyprint_available() -> bool: + """WeasyPrint импортируется только с нативными библиотеками (libgobject и т.д.).""" + if importlib.util.find_spec("weasyprint") is None: + return False + try: + import weasyprint # noqa: F401 + except OSError: + return False + return True + + +def test_dxf_export_round_trips_with_layers() -> None: + payload = _payload() + parcel = geometry.parse_parcel(payload) + variant = geometry.generate(payload)[0] + + data = dxf.export_concept_dxf(parcel, variant) + assert data.startswith(b"AutoCAD Binary DXF") + + with tempfile.NamedTemporaryFile(suffix=".dxf", delete=False) as fh: + fh.write(data) + path = fh.name + try: + doc = ezdxf.readfile(path) + finally: + os.unlink(path) + + msp = doc.modelspace() + by_layer = Counter(e.dxf.layer for e in msp) + # Участок и пятно застройки нарисованы. + assert by_layer["PARCEL"] == 1 + assert by_layer["BUILDABLE"] == 1 + # Секции нарисованы (по одному polyline на секцию + подписи). + n_sections = len(variant.buildings_geojson["features"]) + assert n_sections > 0 + assert by_layer["BUILDINGS"] >= n_sections + + +def test_dxf_building_footprints_have_metric_area() -> None: + from shapely.geometry import Polygon + + payload = _payload() + parcel = geometry.parse_parcel(payload) + variant = geometry.generate(payload)[0] + data = dxf.export_concept_dxf(parcel, variant) + + with tempfile.NamedTemporaryFile(suffix=".dxf", delete=False) as fh: + fh.write(data) + path = fh.name + try: + doc = ezdxf.readfile(path) + finally: + os.unlink(path) + + areas = [] + for e in doc.modelspace(): + if e.dxftype() == "LWPOLYLINE" and e.dxf.layer == "BUILDINGS": + pts = [(p[0], p[1]) for p in e.get_points()] + areas.append(Polygon(pts).area) + assert areas, "no building polylines found" + # Площади секций — десятки/сотни кв.м (метры), не доли (градусы). + for area in areas: + assert 50.0 < area < 2000.0 + + +def test_pdf_html_build_contains_tables() -> None: + variants = geometry.generate(_payload()) + html = pdf._build_html(variants) + assert "Технико-экономические показатели" in html + assert "Финансовая модель" in html + assert "IRR-proxy" in html + + +def test_pdf_html_build_graceful_on_empty() -> None: + html = pdf._build_html([]) + assert "нет вариантов" in html + + +@pytest.mark.skipif( + not _weasyprint_available(), + reason="WeasyPrint native libs unavailable on this host", +) +def test_pdf_export_produces_pdf_bytes() -> None: + variants = geometry.generate(_payload()) + data = pdf.export_concept_pdf(variants) + assert data.startswith(b"%PDF-") + assert len(data) > 1000 diff --git a/backend/tests/services/generative/test_geometry.py b/backend/tests/services/generative/test_geometry.py new file mode 100644 index 00000000..f57f9a4f --- /dev/null +++ b/backend/tests/services/generative/test_geometry.py @@ -0,0 +1,118 @@ +"""Stage 1a tests — parcel parsing, setback buffer, placement grid. + +Deterministic geometry: a known WGS84 rectangle near ЕКБ is parsed into metres, the +setback shrinks it, and the grid covers the buildable area. No network / no DB. +""" + +from __future__ import annotations + +import math + +import pytest +from shapely.geometry import Polygon + +from app.schemas.concept import ConceptInput +from app.services.generative import geometry +from app.services.generative.geometry import ParcelGeometryError, build_placement_grid + +# ~450 m x 310 m rectangle near Екатеринбург (WGS84). Area ~ 0.86 ha. +_PARCEL_COORDS = [ + [60.60, 56.830], + [60.6045, 56.830], + [60.6045, 56.8328], + [60.60, 56.8328], + [60.60, 56.830], +] + + +def _payload(**overrides: object) -> ConceptInput: + base: dict[str, object] = { + "parcel_geojson": {"type": "Polygon", "coordinates": [_PARCEL_COORDS]}, + "housing_class": "comfort", + "target_floors": 9, + "development_type": "mid_rise", + } + base.update(overrides) + return ConceptInput(**base) # type: ignore[arg-type] + + +def test_parse_parcel_reprojects_to_metres() -> None: + parcel = geometry.parse_parcel(_payload()) + # Площадь в кв.м должна быть на масштабе квартала (десятки тысяч кв.м), не градусов. + assert 50_000 < parcel.site_area_sqm < 150_000 + # Buildable меньше участка ровно за счёт отступа. + assert parcel.buildable_area_sqm < parcel.site_area_sqm + assert parcel.setback_m == geometry.DEFAULT_SETBACK_M + + +def test_setback_shrinks_area_by_expected_band() -> None: + setback = 6.0 + parcel = geometry.parse_parcel(_payload(), setback_m=setback) + # Грубая нижняя граница убыли: периметр * setback (внутренний буфер). + perimeter = parcel.polygon_m.length + expected_loss = perimeter * setback + actual_loss = parcel.site_area_sqm - parcel.buildable_area_sqm + # Внутренний буфер срезает примерно полосу шириной setback по периметру (±50%). + assert 0.5 * expected_loss < actual_loss < 1.5 * expected_loss + + +def test_grid_cells_lie_inside_buildable() -> None: + parcel = geometry.parse_parcel(_payload(), grid_step_m=6.0) + assert len(parcel.grid) > 0 + for cell in parcel.grid: + assert parcel.buildable_m.covers(cell.as_polygon().centroid) + + +def test_parse_is_deterministic() -> None: + a = geometry.parse_parcel(_payload()) + b = geometry.parse_parcel(_payload()) + assert a.site_area_sqm == b.site_area_sqm + assert a.buildable_area_sqm == b.buildable_area_sqm + assert len(a.grid) == len(b.grid) + assert [(c.cx, c.cy) for c in a.grid] == [(c.cx, c.cy) for c in b.grid] + + +def test_feature_geojson_is_accepted() -> None: + # GeoJSON Feature (а не голая geometry) тоже парсится. + payload = _payload( + parcel_geojson={ + "type": "Feature", + "properties": {}, + "geometry": {"type": "Polygon", "coordinates": [_PARCEL_COORDS]}, + } + ) + parcel = geometry.parse_parcel(payload) + assert parcel.site_area_sqm > 0 + + +def test_tiny_parcel_rejected_after_setback() -> None: + # ~16 m x 16 m: отступ 6 м с каждой стороны схлопывает пятно застройки. + tiny = [ + [60.60, 56.830], + [60.60015, 56.830], + [60.60015, 56.83015], + [60.60, 56.83015], + [60.60, 56.830], + ] + payload = _payload(parcel_geojson={"type": "Polygon", "coordinates": [tiny]}) + with pytest.raises(ParcelGeometryError): + geometry.parse_parcel(payload) + + +def test_non_polygon_rejected() -> None: + payload = _payload( + parcel_geojson={"type": "Point", "coordinates": [60.60, 56.83]} + ) + with pytest.raises(ParcelGeometryError): + geometry.parse_parcel(payload) + + +def test_build_placement_grid_anchors_are_step_aligned() -> None: + # Простой метрический квадрат 30x30 м, шаг 10 -> 3x3 = 9 ячеек. + square = Polygon([(0, 0), (30, 0), (30, 30), (0, 30)]) + cells = build_placement_grid(square, 10.0) + assert len(cells) == 9 + # Центры на полушаге от кратных шагу. + for cell in cells: + assert math.isclose((cell.cx - 5.0) % 10.0, 0.0, abs_tol=1e-6) + assert math.isclose((cell.cy - 5.0) % 10.0, 0.0, abs_tol=1e-6) diff --git a/backend/tests/services/generative/test_placement.py b/backend/tests/services/generative/test_placement.py new file mode 100644 index 00000000..558b556c --- /dev/null +++ b/backend/tests/services/generative/test_placement.py @@ -0,0 +1,116 @@ +"""Stage 1b tests — greedy placement, STRtree collisions, gaps, strategies. + +Asserts the structural guarantees of the greedy sweep: footprints stay inside the +buildable area, respect the inter-section gap (no overlaps), the three strategies +differ, the coverage cap bounds density, and the result is deterministic. +""" + +from __future__ import annotations + +from shapely.geometry import shape + +from app.schemas.concept import ConceptInput +from app.services.generative import geometry, placement + +_PARCEL_COORDS = [ + [60.60, 56.830], + [60.6045, 56.830], + [60.6045, 56.8328], + [60.60, 56.8328], + [60.60, 56.830], +] + + +def _payload(**overrides: object) -> ConceptInput: + base: dict[str, object] = { + "parcel_geojson": {"type": "Polygon", "coordinates": [_PARCEL_COORDS]}, + "housing_class": "comfort", + "target_floors": 9, + "development_type": "mid_rise", + } + base.update(overrides) + return ConceptInput(**base) # type: ignore[arg-type] + + +def test_all_three_strategies_present() -> None: + parcel = geometry.parse_parcel(_payload()) + variants = placement.place_all_strategies(parcel, _payload()) + assert {v.strategy for v in variants} == {"max_area", "max_insolation", "balanced"} + + +def test_footprints_inside_buildable_and_non_overlapping() -> None: + parcel = geometry.parse_parcel(_payload()) + spec = next(s for s in placement._STRATEGIES if s.name == "max_area") + footprints = placement._greedy_place(parcel, spec, coverage_cap=0.45) + assert len(footprints) > 0 + for fp in footprints: + # Внутри пятна застройки (с допуском на численную погрешность буфера). + assert parcel.buildable_m.buffer(0.01).covers(fp) + # Никакие две секции не перекрываются (разрыв gap_m выдержан). + for i, a in enumerate(footprints): + for b in footprints[i + 1 :]: + assert not a.buffer(-0.01).intersects(b.buffer(-0.01)) + + +def test_gap_between_sections_respected() -> None: + parcel = geometry.parse_parcel(_payload()) + spec = next(s for s in placement._STRATEGIES if s.name == "max_insolation") + footprints = placement._greedy_place(parcel, spec, coverage_cap=0.45) + # Минимальное расстояние между любыми двумя секциями >= gap_m (с допуском). + for i, a in enumerate(footprints): + for b in footprints[i + 1 :]: + assert a.distance(b) >= spec.gap_m - 0.5 + + +def test_max_area_denser_than_max_insolation() -> None: + payload = _payload() + parcel = geometry.parse_parcel(payload) + variants = {v.strategy: v for v in placement.place_all_strategies(parcel, payload)} + # Максимум площади даёт большее пятно/жилую, чем максимум инсоляции. + assert ( + variants["max_area"].teap.built_area_sqm + > variants["max_insolation"].teap.built_area_sqm + ) + assert ( + variants["max_area"].teap.residential_area_sqm + > variants["max_insolation"].teap.residential_area_sqm + ) + + +def test_coverage_cap_bounds_built_area() -> None: + # high_rise cap = 0.50; пятно не должно его превышать (+небольшой запас на 1 секцию). + payload = _payload(development_type="high_rise") + parcel = geometry.parse_parcel(payload) + variants = placement.place_all_strategies(parcel, payload) + cap = placement._COVERAGE_CAP_BY_TYPE["high_rise"] + for v in variants: + coverage = v.teap.built_area_sqm / parcel.buildable_area_sqm + # +0.05: цикл останавливается ПОСЛЕ секции, перешагнувшей порог. + assert coverage <= cap + 0.05 + + +def test_buildings_geojson_features_are_valid_polygons() -> None: + payload = _payload() + parcel = geometry.parse_parcel(payload) + variant = placement.place_all_strategies(parcel, payload)[0] + fc = variant.buildings_geojson + assert fc["type"] == "FeatureCollection" + features = fc["features"] + assert isinstance(features, list) and len(features) > 0 + for feat in features: + geom = shape(feat["geometry"]) + assert geom.geom_type == "Polygon" + assert geom.is_valid + assert feat["properties"]["floors"] >= 1 + + +def test_placement_deterministic() -> None: + payload = _payload() + p1 = geometry.parse_parcel(payload) + p2 = geometry.parse_parcel(payload) + v1 = placement.place_all_strategies(p1, payload) + v2 = placement.place_all_strategies(p2, payload) + for a, b in zip(v1, v2, strict=True): + assert a.teap.apartments_count == b.teap.apartments_count + assert a.teap.built_area_sqm == b.teap.built_area_sqm + assert len(a.buildings_geojson["features"]) == len(b.buildings_geojson["features"]) diff --git a/backend/tests/services/generative/test_teap_financial.py b/backend/tests/services/generative/test_teap_financial.py new file mode 100644 index 00000000..c28176fd --- /dev/null +++ b/backend/tests/services/generative/test_teap_financial.py @@ -0,0 +1,114 @@ +"""Stage 1c tests — ТЭП and financial model arithmetic. + +Pure-arithmetic unit tests against known footprint geometry: GFA, residential area, +apartment count, FAR, parking, revenue/cost/margin and the IRR-proxy clamp. +""" + +from __future__ import annotations + +from shapely.geometry import box + +from app.schemas.concept import TEAP +from app.services.generative import financial, teap + +# Один прямоугольник 20 x 10 = 200 кв.м пятна. +_FOOTPRINT = box(0, 0, 20, 10) + + +def test_teap_basic_arithmetic() -> None: + result = teap.compute_teap( + footprints=[_FOOTPRINT], + floors=10, + site_area_sqm=1000.0, + housing_class="comfort", + ) + assert result.built_area_sqm == 200.0 + # GFA = пятно * этажность. + assert result.total_floor_area_sqm == 2000.0 + # FAR = GFA / участок. + assert result.density == 2.0 + # Жилая = GFA * efficiency(comfort=0.78). + assert result.residential_area_sqm == 1560.0 + # Квартир = floor(жилая / avg(comfort=55)). + assert result.apartments_count == int(1560.0 // 55.0) + # Парковка = ceil(квартир * 1.0). + assert result.parking_spaces == result.apartments_count + + +def test_teap_class_changes_efficiency_and_lot() -> None: + econ = teap.compute_teap( + footprints=[_FOOTPRINT], floors=10, site_area_sqm=1000.0, housing_class="econom" + ) + biz = teap.compute_teap( + footprints=[_FOOTPRINT], floors=10, site_area_sqm=1000.0, housing_class="business" + ) + # Эконом эффективнее по площади -> больше жилой при той же GFA. + assert econ.residential_area_sqm > biz.residential_area_sqm + # Бизнес — крупнее лот -> меньше квартир. + assert biz.apartments_count < econ.apartments_count + # Бизнес — выше норма парковки на квартиру. + assert biz.parking_spaces / max(1, biz.apartments_count) >= 1.4 + + +def test_teap_zero_site_area_no_division_error() -> None: + result = teap.compute_teap( + footprints=[_FOOTPRINT], floors=5, site_area_sqm=0.0, housing_class="comfort" + ) + assert result.density == 0.0 + + +def test_teap_empty_placement_is_zeroed() -> None: + result = teap.compute_teap( + footprints=[], floors=9, site_area_sqm=1000.0, housing_class="comfort" + ) + assert result.built_area_sqm == 0.0 + assert result.total_floor_area_sqm == 0.0 + assert result.apartments_count == 0 + assert result.parking_spaces == 0 + + +def _teap(residential: float, gfa: float) -> TEAP: + return TEAP( + built_area_sqm=100.0, + total_floor_area_sqm=gfa, + residential_area_sqm=residential, + apartments_count=10, + density=1.0, + parking_spaces=10, + ) + + +def test_financial_revenue_cost_margin() -> None: + t = _teap(residential=1000.0, gfa=1300.0) + model = financial.compute_financial( + teap=t, housing_class="comfort", land_cost_rub=50_000_000.0 + ) + # revenue = 1000 * 145_000. + assert model.revenue_rub == 1000.0 * 145_000.0 + # cost = 1300 * 88_000 + land. + assert model.cost_rub == 1300.0 * 88_000.0 + 50_000_000.0 + assert model.gross_margin_rub == model.revenue_rub - model.cost_rub + + +def test_financial_land_cost_optional() -> None: + t = _teap(residential=1000.0, gfa=1300.0) + no_land = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None) + with_land = financial.compute_financial( + teap=t, housing_class="comfort", land_cost_rub=10_000_000.0 + ) + # Земля увеличивает затраты ровно на свою стоимость. + assert with_land.cost_rub - no_land.cost_rub == 10_000_000.0 + + +def test_financial_irr_proxy_clamped() -> None: + # Огромная маржа -> irr-proxy зажат в [-1, 1]. + t = _teap(residential=100_000.0, gfa=1.0) + model = financial.compute_financial(teap=t, housing_class="business", land_cost_rub=None) + assert -1.0 <= model.irr <= 1.0 + + +def test_financial_zero_cost_no_division_error() -> None: + t = _teap(residential=0.0, gfa=0.0) + model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None) + assert model.irr == 0.0 + assert model.cost_rub == 0.0