feat(generative): exporters (dxf/pdf) + generative test suite (#54 #55 #56)
Some checks failed
CI / frontend-tests (push) Has been skipped
CI / changes (push) Successful in 10s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 7m39s
CI / backend-tests (pull_request) Successful in 7m39s
Deploy / deploy (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy / changes (push) Successful in 6s

Дополняет движок: exporters/{dxf,pdf}.py (ezdxf + WeasyPrint lazy) + 5 тест-модулей
(geometry/placement/teap_financial/exporters/api). Не вошли в предыдущий commit
(untracked-директории).
This commit is contained in:
Light1YT 2026-06-13 21:33:20 +05:00
parent 3b79533f9f
commit 8242e51b61
8 changed files with 924 additions and 0 deletions

View file

@ -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"]

View file

@ -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"]

View file

@ -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"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
)
rows: list[tuple[str, list[str]]] = [
("Пятно застройки, кв.м", [_fmt_int(v.teap.built_area_sqm) for v in variants]),
("Общая площадь (GFA), кв.м", [_fmt_int(v.teap.total_floor_area_sqm) for v in variants]),
("Жилая площадь, кв.м", [_fmt_int(v.teap.residential_area_sqm) for v in variants]),
("Квартир, шт", [_fmt_int(v.teap.apartments_count) for v in variants]),
("Плотность (FAR)", [f"{v.teap.density:.2f}" for v in variants]),
("Машиномест", [_fmt_int(v.teap.parking_spaces) for v in variants]),
]
body = "".join(
"<tr><td class='row'>"
+ html.escape(label)
+ "</td>"
+ "".join(f"<td>{html.escape(cell)}</td>" for cell in cells)
+ "</tr>"
for label, cells in rows
)
return (
"<table><caption>Технико-экономические показатели</caption>"
f"<thead><tr><th class='row'>Показатель</th>{headers}</tr></thead>"
f"<tbody>{body}</tbody></table>"
)
def _financial_table(variants: Sequence[ConceptVariant]) -> str:
"""HTML-таблица финмодели (деньги в млн руб; IRR — proxy, помечен)."""
headers = "".join(
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
)
rows: list[tuple[str, list[str]]] = [
("Выручка, млн руб", [_fmt_money(v.financial.revenue_rub) for v in variants]),
("Затраты, млн руб", [_fmt_money(v.financial.cost_rub) for v in variants]),
("Валовая маржа, млн руб", [_fmt_money(v.financial.gross_margin_rub) for v in variants]),
("IRR-proxy", [f"{v.financial.irr * 100:.1f}%" for v in variants]),
]
body = "".join(
"<tr><td class='row'>"
+ html.escape(label)
+ "</td>"
+ "".join(f"<td>{html.escape(cell)}</td>" for cell in cells)
+ "</tr>"
for label, cells in rows
)
return (
"<table><caption>Финансовая модель (упрощённая)</caption>"
f"<thead><tr><th class='row'>Показатель</th>{headers}</tr></thead>"
f"<tbody>{body}</tbody></table>"
)
def _build_html(variants: Sequence[ConceptVariant]) -> str:
if not variants:
return (
f"<html><head><meta charset='utf-8'><style>{_CSS}</style></head>"
f"<body><h1>{html.escape(_TITLE)}</h1>"
f"<p class='sub'>{html.escape(_SUBTITLE)}</p>"
f"<p>{_DASH} нет вариантов для отображения</p></body></html>"
)
return (
f"<html><head><meta charset='utf-8'><style>{_CSS}</style></head><body>"
f"<h1>{html.escape(_TITLE)}</h1>"
f"<p class='sub'>{html.escape(_SUBTITLE)}</p>"
f"{_teap_table(variants)}"
f"{_financial_table(variants)}"
"<p class='sub'>IRR-proxy — аннуализированная маржа-на-затраты без "
"дисконтирования (не настоящий IRR). Цены и себестоимость — рыночные "
"ориентиры, не калиброванная модель ценообразования.</p>"
"</body></html>"
)
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"]

View file

@ -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",
}

View file

@ -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

View file

@ -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)

View file

@ -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"])

View file

@ -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