"""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 BuildingProgramItem, ConceptInput from app.services.generative import catalog, 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_program_footprint_override_changes_section_area() -> None: # Stage 3c: ручное пятно (footprint_w_m/_d_m) кладётся вместо каталожного. parcel = geometry.parse_parcel(_payload()) section_type = next(iter(catalog.available_section_types())) house = catalog.get_house_type(section_type) override = BuildingProgramItem( section_type=section_type, floors=10, count=2, footprint_w_m=10.0, footprint_d_m=10.0, ) placed = placement.place_program(parcel, [override]) assert placed.placed_count >= 1 # Каждая размещённая секция несёт пятно override (10×10 = 100 м²), не каталог. for sec in placed.sections: assert abs(sec.footprint.area - 100.0) < 1.0 # Без override — пятно из каталога (контроль: байт-в-байт прежнее поведение). base = BuildingProgramItem(section_type=section_type, floors=10, count=2) placed_base = placement.place_program(parcel, [base]) catalog_area = house.footprint_w_m * house.footprint_d_m for sec in placed_base.sections: assert abs(sec.footprint.area - catalog_area) < 1.0 def test_program_partial_footprint_falls_back_to_catalog() -> None: # Только ширина задана (без глубины) → трактуем как «не задано», берём каталог. parcel = geometry.parse_parcel(_payload()) section_type = next(iter(catalog.available_section_types())) house = catalog.get_house_type(section_type) item = BuildingProgramItem(section_type=section_type, floors=10, count=1, footprint_w_m=10.0) placed = placement.place_program(parcel, [item]) catalog_area = house.footprint_w_m * house.footprint_d_m for sec in placed.sections: assert abs(sec.footprint.area - catalog_area) < 1.0 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"])