"""초기 계획노선 갈래(PLAN 20장) — `B05_Profile_Engine_RouteInitial` 합성 지형 시험. ./venv/Scripts/python.exe -m pytest resources/tester/test_route_initial.py -q """ import numpy as np import pytest from B05_Profile.B05_Profile_Engine_RouteInitial import ( MODES, PENDING, STREAM_OFFSET_M, Terrain, generate_initial_route, grade_line, resolve_weights, stream_lines, ) GRADE = 0.14 RADIUS = 12.0 def _terrain(z_of) -> Terrain: x = np.arange(0.0, 802.0, 2.0) y = np.arange(0.0, 402.0, 2.0) xx, yy = np.meshgrid(x, y) return Terrain(x, y, z_of(xx, yy), np.ones(xx.shape, dtype=bool)) def _line(y: float) -> list[tuple[float, float]]: return [(float(x), y) for x in np.arange(50.0, 752.0, 4.0)] @pytest.fixture(scope="module") def hill() -> Terrain: """가운데 솟은 언덕(높이 40 m) — 곧은 예상노선이 꼭대기를 넘는다.""" return _terrain( lambda xx, yy: 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2) ) def _run(terrain: Terrain, mode: str, streams=(), weights=None) -> dict: return generate_initial_route( _line(200.0), terrain, list(streams), mode=mode, weights=weights, max_grade=GRADE, min_radius_m=RADIUS, ) def test_follow_keeps_expected_route(hill: Terrain) -> None: result = _run(hill, "follow") assert result["metrics"]["offset_max_m"] < 1.0 assert result["weights"] == {"expected": 100.0, "earthwork": 0.0, "grade": 0.0} assert result["criteria"]["pending"] == list(PENDING) @pytest.mark.parametrize("mode", ["earthwork", "gentle", "weighted"]) def test_search_modes_beat_follow_on_hill(hill: Terrain, mode: str) -> None: follow = _run(hill, "follow")["metrics"] result = _run(hill, mode) metrics = result["metrics"] # 언덕을 돌아가 기울기 · 토공이 준다 assert metrics["max_grade_pct"] < follow["max_grade_pct"] assert metrics["cut_m3"] + metrics["fill_m3"] < follow["cut_m3"] + follow["fill_m3"] assert metrics["over_limit"]["grade"] <= follow["over_limit"]["grade"] # 기점 · 종점은 옮기지 않고 복도(100 m) 밖으로 안 나감 assert result["planned"][0] == [50.0, 200.0] assert result["planned"][-1] == [750.0, 200.0] assert metrics["offset_max_m"] <= result["criteria"]["corridor_m"] + 4.0 assert result["nodes"] and result["curves"] is not None def test_weighted_expected_share_pulls_route_back(hill: Terrain) -> None: near = _run(hill, "weighted", weights={"expected": 90, "earthwork": 5, "grade": 5}) far = _run(hill, "weighted", weights={"expected": 10, "earthwork": 45, "grade": 45}) assert near["metrics"]["offset_avg_m"] < far["metrics"]["offset_avg_m"] def test_stream_offset_is_common_constraint() -> None: """평지 · 예상노선 20 m 옆을 나란히 흐르는 소하천 — 추종은 초과 1 · 탐색은 비켜 감.""" flat = _terrain(lambda xx, yy: np.zeros_like(xx)) stream = [[(0.0, 220.0), (800.0, 220.0)]] follow = _run(flat, "follow", streams=stream)["metrics"] moved = _run(flat, "earthwork", streams=stream)["metrics"] assert follow["over_limit"]["stream"] == 1 assert moved["over_limit"]["stream"] == 0 assert moved["offset_avg_m"] > STREAM_OFFSET_M - 20.0 - 10.0 def test_stream_crossing_is_allowed() -> None: flat = _terrain(lambda xx, yy: np.zeros_like(xx)) crossing = [[(400.0, 0.0), (400.0, 400.0)]] assert _run(flat, "follow", streams=crossing)["metrics"]["over_limit"]["stream"] == 0 def test_grade_line_stays_within_limit() -> None: ground = np.array([0.0, 0.0, 5.0, 10.0, 10.0, 2.0, 0.0]) line = grade_line(ground, 2.0, GRADE) assert np.all(np.abs(np.diff(line)) <= GRADE * 2.0 + 1e-9) def test_weights_and_stream_filter() -> None: assert resolve_weights("weighted", None) == {"expected": 50.0, "earthwork": 25.0, "grade": 25.0} assert resolve_weights("gentle", {"expected": 99})["grade"] == 100.0 assert set(MODES) == {"follow", "earthwork", "gentle", "weighted"} features = [ { "properties": {"구분": "세류"}, "geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}, }, { "properties": {"구분": "소하천"}, "geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}, }, ] assert len(stream_lines(features)) == 1 def test_unknown_mode_rejected(hill: Terrain) -> None: with pytest.raises(ValueError): _run(hill, "shortest")