fix(B05): 초기노선 공통 제약을 넘지 못하게 — 지형상 불가능한 곳만 목록(측점 · 값 · 사유)
- 종단기울기 상한 넘는 링크 · 계류 버퍼 안 나란히 달리기는 탐색에서 뺌(추종 갈래 포함) - 최소 곡선반지름: 이웃 방향으로만 꺾고 접선 길이만큼 곧게 간 뒤 다시 꺾음 · 노드마다 원호 - 길이 없을 때만 막은 기울기(규칙 지키는 어떤 길도 넘어야 하는 값)를 이분 탐색해 그 위는 막고 넘긴 곳을 metrics.violations 에 - over_limit 수 = 목록 수 · 종단기울기는 5 m 로 고른 지반의 측점 20 m 기울기(탐색 · 비교값 같은 잣대) - 비교값 · 목록은 새 파일 B05_Profile_Engine_RouteInitial_Metrics.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
"""초기 계획노선 갈래(PLAN 20장) — `B05_Profile_Engine_RouteInitial` 합성 지형 시험.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_route_initial.py -q
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_route_initial.py -q
|
||||
|
||||
공통 제약(종단기울기 상한 · 최소 곡선반지름 · 계류 이격)은 넘지 못함 — 지형상 불가능할 때만
|
||||
`metrics.violations` 에 측점 · 값 · 사유로 뜨고 `over_limit.total` 은 그 수와 같다.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
@@ -12,12 +15,12 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import (
|
||||
STREAM_OFFSET_M,
|
||||
Terrain,
|
||||
generate_initial_route,
|
||||
grade_line,
|
||||
read_initial_choice,
|
||||
resolve_weights,
|
||||
stream_lines,
|
||||
write_initial_choice,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import grade_line, station_label
|
||||
|
||||
GRADE = 0.14
|
||||
RADIUS = 12.0
|
||||
@@ -34,14 +37,6 @@ 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),
|
||||
@@ -54,71 +49,98 @@ def _run(terrain: Terrain, mode: str, streams=(), weights=None) -> dict:
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
def _kept(result: dict) -> None:
|
||||
"""제약을 지켰거나, 못 지킨 곳이 목록에 있고 수가 같음."""
|
||||
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"]
|
||||
assert metrics["over_limit"]["total"] == len(metrics["violations"])
|
||||
if not metrics["violations"]:
|
||||
assert metrics["max_grade_pct"] <= GRADE * 100.0 + 1e-6
|
||||
assert result["relaxed"] is False
|
||||
for curve in result["curves"]:
|
||||
assert curve["radius_m"] >= RADIUS - 1e-6 or curve["violations"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def hill() -> Terrain:
|
||||
"""가운데 솟은 언덕(높이 40 m) — 곧은 예상노선이 꼭대기(측면 27 %)를 넘는다."""
|
||||
return _terrain(
|
||||
lambda xx, yy: 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", MODES)
|
||||
def test_every_mode_keeps_grade_limit_on_hill(hill: Terrain, mode: str) -> None:
|
||||
result = _run(hill, mode)
|
||||
_kept(result)
|
||||
metrics = result["metrics"]
|
||||
assert metrics["over_limit"]["total"] == 0
|
||||
assert metrics["max_grade_pct"] <= GRADE * 100.0 + 1e-6
|
||||
# 기점 · 종점은 옮기지 않고 복도(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_follow_stays_on_expected_where_allowed() -> None:
|
||||
"""고른 경사(2 %) — 추종은 예상노선을 그대로(격자 칸 반 안쪽)."""
|
||||
ramp = _terrain(lambda xx, yy: 0.02 * xx)
|
||||
result = _run(ramp, "follow")
|
||||
_kept(result)
|
||||
assert result["metrics"]["offset_max_m"] <= 3.0
|
||||
|
||||
|
||||
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"]
|
||||
assert near["metrics"]["offset_avg_m"] <= far["metrics"]["offset_avg_m"]
|
||||
|
||||
|
||||
def test_stream_offset_is_common_constraint() -> None:
|
||||
"""평지 · 예상노선 20 m 옆을 나란히 흐르는 소하천 — 추종은 초과 1 · 탐색은 비켜 감."""
|
||||
def test_stream_offset_is_hard() -> None:
|
||||
"""평지 · 예상노선 20 m 옆을 나란히 흐르는 소하천 — 모든 갈래가 버퍼를 비켜 감."""
|
||||
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
|
||||
for mode in MODES:
|
||||
result = _run(flat, mode, streams=stream)
|
||||
_kept(result)
|
||||
assert result["metrics"]["over_limit"]["stream"] == 0
|
||||
assert result["metrics"]["offset_avg_m"] > STREAM_OFFSET_M - 20.0 - 30.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
|
||||
result = _run(flat, "follow", streams=crossing)
|
||||
_kept(result)
|
||||
assert result["metrics"]["over_limit"]["total"] == 0
|
||||
|
||||
|
||||
def test_terrain_impossible_is_listed() -> None:
|
||||
"""복도 전체를 가로지르는 20 m 벽 — 어떤 길도 상한을 넘음 → 목록 · 사유 · 막은 기울기."""
|
||||
wall = _terrain(lambda xx, yy: np.where(xx > 400.0, 20.0, 0.0))
|
||||
result = _run(wall, "weighted")
|
||||
_kept(result)
|
||||
assert result["relaxed"] is True
|
||||
assert result["terrain_bound_pct"] > GRADE * 100.0
|
||||
grades = [v for v in result["metrics"]["violations"] if v["kind"] == "grade"]
|
||||
assert grades and all("지형상 불가능" in v["reason"] for v in grades)
|
||||
assert all(v["station"].startswith("No.") for v in grades)
|
||||
|
||||
|
||||
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)
|
||||
assert station_label(125.0) == "No.6+5.0"
|
||||
|
||||
|
||||
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"}
|
||||
line = {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}
|
||||
features = [
|
||||
{
|
||||
"properties": {"구분": "세류"},
|
||||
"geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]},
|
||||
},
|
||||
{
|
||||
"properties": {"구분": "소하천"},
|
||||
"geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]},
|
||||
},
|
||||
{"properties": {"구분": "세류"}, "geometry": line},
|
||||
{"properties": {"구분": "소하천"}, "geometry": line},
|
||||
]
|
||||
assert len(stream_lines(features)) == 1
|
||||
|
||||
@@ -128,6 +150,10 @@ def test_unknown_mode_rejected(hill: Terrain) -> None:
|
||||
_run(hill, "shortest")
|
||||
|
||||
|
||||
def test_criteria_pending(hill: Terrain) -> None:
|
||||
assert _run(hill, "follow")["criteria"]["pending"] == list(PENDING)
|
||||
|
||||
|
||||
def test_initial_choice_roundtrip(tmp_path) -> None:
|
||||
"""[확인] 때 적은 현재 초기값을 다시 읽음 · 없거나 깨지면 추종."""
|
||||
assert read_initial_choice(tmp_path)["logic"] == "follow"
|
||||
|
||||
Reference in New Issue
Block a user