feat(B05): 초기 계획노선 갈래 — 추종 · 토공 최소 · 기울기 순한 · 비중(POST /route/initial)
- 갈래 인자 하나로 초기노선 생성 · follow 는 지금 로직 그대로(예상노선 폴리라인화) - 탐색 = 예상노선 둘레 100 m 복도 · 4 m 격자 · 16 방향 + 진행 방향 기억 Dijkstra(꺾을 때 R·θ 벌점) - 비중 = 예상 이격 · 토공 · 기울기 세 목표를 복도 평균으로 정규화해 가중합(합 100 · 기본 50/25/25) - 공통 제약(종단기울기 상한 · 최소 곡선반지름 · 계류 이격) — 탐색 벌점 · 원호 끼우기 · 초과 구간 수 - 비교값: 연장 · 최대/평균 종단기울기 · 절성토량 · 기준 초과 구간 수 · 예상노선 이격 - 파일 안 씀(캐시용) · 기준값은 지식DB 후보 임시값(사용자 확정 대기) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"""초기 계획노선 갈래(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")
|
||||
Reference in New Issue
Block a user