Files
Aislo/0_old/utils/test_route_solver_ridgevalley.py
T

264 lines
13 KiB
Python

import math
import sys
from pathlib import Path
import numpy as np
# Add project root to sys.path
root_dir = Path(__file__).resolve().parent.parent
if str(root_dir) not in sys.path:
sys.path.insert(0, str(root_dir))
import config
from utils.route_solver_ridgevalley import (
_Grid, _segment_feasible, _turn_angle, _fillet_alignment,
solve_ridge_valley_route, resolve_grade_bounds,
)
from utils.route_solver import _circumradius_2d
INSTANCE_DIR = root_dir / "instance"
PROJECT_ID = "upload_sample"
FILTER_KEY = "csf"
DTM_PATH = INSTANCE_DIR / PROJECT_ID / "terrain_models" / f"dtm_{FILTER_KEY}_smooth.npz"
def _plane_grid(grade_x=0.10, rows=60, cols=120, res=2.0, bump_height=0.0):
"""x 방향 정속경사 평면 (+선택적 중앙 볼록 지형)."""
x = np.arange(cols, dtype=np.float64) * res
y = np.arange(rows, dtype=np.float64) * res
xx, yy = np.meshgrid(x, y)
z = grade_x * xx
if bump_height > 0:
cx, cy = x[cols // 2], y[rows // 2]
z = z + bump_height * np.exp(-(((xx - cx) / 20.0) ** 2 + ((yy - cy) / 20.0) ** 2))
valid = np.ones_like(z, dtype=bool)
return _Grid(x, y, z, valid, res)
def test_segment_feasible_constant_grade_plane():
"""10% 정속경사 평면 위 직선은 8~14% 창과 ±0.5% 허용오차를 만족해야 한다."""
grid = _plane_grid(grade_x=0.10)
barrier = np.zeros(grid.z.shape, dtype=bool)
a = np.array([10.0, 60.0, grid.z_at(10.0, 60.0)])
b = np.array([150.0, 60.0, grid.z_at(150.0, 60.0)])
ok = _segment_feasible(a, b, grid, barrier, [], 0.08, 0.14, 0.005, 15.0)
assert ok, "정속 10% 경사 평면의 직선 세그먼트는 성립해야 한다"
print("[ok] constant 10% plane segment is feasible")
def test_segment_rejected_outside_grade_window():
"""5%(하한 미달)와 20%(상한 초과) 경사는 모두 탈락해야 한다."""
for g in (0.05, 0.20):
grid = _plane_grid(grade_x=g)
barrier = np.zeros(grid.z.shape, dtype=bool)
a = np.array([10.0, 60.0, grid.z_at(10.0, 60.0)])
b = np.array([150.0, 60.0, grid.z_at(150.0, 60.0)])
ok = _segment_feasible(a, b, grid, barrier, [], 0.08, 0.14, 0.005, 15.0)
assert not ok, f"경사 {g*100:.0f}% 세그먼트는 8~14% 창에서 탈락해야 한다"
print("[ok] segments outside the 8~14% window are rejected")
def test_segment_rejected_by_terrain_deviation():
"""경로 중앙의 볼록 지형(±0.5% 초과 편차)이 있으면 정속경사가 성립하지 않는다."""
grid = _plane_grid(grade_x=0.10, bump_height=8.0)
barrier = np.zeros(grid.z.shape, dtype=bool)
a = np.array([10.0, 60.0, grid.z_at(10.0, 60.0)])
b = np.array([230.0, 60.0, grid.z_at(230.0, 60.0)])
ok = _segment_feasible(a, b, grid, barrier, [], 0.08, 0.14, 0.005, 15.0)
assert not ok, "지형이 정속경사선에서 크게 벗어나면 세그먼트는 탈락해야 한다"
print("[ok] terrain deviating beyond +/-0.5% tolerance rejects the segment")
def test_segment_blocked_by_forbidden_circle():
"""FP(금지구역) 원을 지나는 직선은 탈락해야 한다."""
grid = _plane_grid(grade_x=0.10)
barrier = np.zeros(grid.z.shape, dtype=bool)
a = np.array([10.0, 60.0, grid.z_at(10.0, 60.0)])
b = np.array([150.0, 60.0, grid.z_at(150.0, 60.0)])
fp = [{"x": 80.0, "y": 60.0, "radius_m": 15.0}]
ok = _segment_feasible(a, b, grid, barrier, fp, 0.08, 0.14, 0.005, 15.0)
assert not ok, "금지구역을 관통하는 세그먼트는 탈락해야 한다"
print("[ok] forbidden-zone circles block the segment")
def test_resolve_grade_bounds_defaults():
"""옵션 미지정 시 config 기본값(8~14%)이 양방향에 그대로 적용되어야 한다."""
gb = resolve_grade_bounds({})
assert abs(gb["min_uphill_grade"] - config.ROUTE_ALT_MIN_GRADE) < 1e-9
assert abs(gb["min_downhill_grade"] - config.ROUTE_ALT_MIN_GRADE) < 1e-9
assert abs(gb["max_uphill_grade"] - config.ROUTE_ALT_MAX_GRADE) < 1e-9
assert abs(gb["max_downhill_grade"] - config.ROUTE_ALT_MAX_GRADE) < 1e-9
print("[ok] resolve_grade_bounds defaults to config 8~14% both directions")
def test_resolve_grade_bounds_lower_bound_same_mechanism_as_upper():
"""하한(min_uphill_grade/min_downhill_grade)이 상한과 동일한 방식(옵션 오버라이드,
0 은 유효한 '제한 없음')으로 적용되어야 한다."""
# 하한을 0 으로 명시하면 falsy(0) 함정 없이 실제로 0 이 적용되어야 한다.
gb = resolve_grade_bounds({"min_uphill_grade": 0.0, "min_downhill_grade": 0.0})
assert gb["min_uphill_grade"] == 0.0
assert gb["min_downhill_grade"] == 0.0
assert gb["min_grade_for_edges"] == 0.0
# 방향별로 다른 값을 주면 그대로 반영되어야 한다 (상한과 동일한 방식).
gb2 = resolve_grade_bounds({
"min_uphill_grade": 0.05, "min_downhill_grade": 0.10,
"max_uphill_grade": 0.20, "max_downhill_grade": 0.16,
})
assert abs(gb2["min_uphill_grade"] - 0.05) < 1e-9
assert abs(gb2["min_downhill_grade"] - 0.10) < 1e-9
assert abs(gb2["max_uphill_grade"] - 0.20) < 1e-9
assert abs(gb2["max_downhill_grade"] - 0.16) < 1e-9
# 그래프 엣지용 보수값: 하한은 더 큰 쪽(0.10), 상한은 더 작은 쪽(0.16).
assert abs(gb2["min_grade_for_edges"] - 0.10) < 1e-9
assert abs(gb2["max_grade_for_edges"] - 0.16) < 1e-9
print("[ok] min_uphill/downhill_grade applied via the same override mechanism as max")
def test_turn_angle():
"""직진은 0, 직각 꺾임은 90도."""
assert abs(_turn_angle((0, 0), (1, 0), (2, 0))) < 1e-9
assert abs(_turn_angle((0, 0), (1, 0), (1, 1)) - math.pi / 2) < 1e-9
print("[ok] turn angle: straight=0, right angle=90deg")
def test_fillet_alignment_respects_min_radius():
"""직각 꺾임 노드열에 R=12m fillet 을 넣으면 어느 지점도 반경이 그보다
(표본화 오차 이상으로) 작아지지 않고, 양 끝점은 보존된다."""
R = 12.0
nodes = [[0.0, 0.0, 0.0], [100.0, 0.0, 10.0], [100.0, 100.0, 22.0]]
poly, _radii = _fillet_alignment(nodes, R, step_m=2.0)
assert np.allclose(poly[0], nodes[0]), "시작점이 보존되어야 한다"
assert np.allclose(poly[-1], nodes[-1]), "끝점이 보존되어야 한다"
min_r = float("inf")
for i in range(1, len(poly) - 1):
r = _circumradius_2d(poly[i - 1], poly[i], poly[i + 1])
min_r = min(min_r, r)
assert min_r > R * 0.9, f"최소 곡선반경 {min_r:.2f}m 가 기준 {R}m 에 크게 미달"
print(f"[ok] fillet keeps min radius ~{min_r:.1f}m >= {R}m (with sampling slack)")
def test_fillet_alignment_keeps_constant_grade_on_tangents():
"""직선(tangent) 구간의 종단경사는 세그먼트 설계경사 그대로 일정해야 한다."""
nodes = [[0.0, 0.0, 0.0], [200.0, 0.0, 20.0], [200.0, 200.0, 44.0]]
poly, radii = _fillet_alignment(nodes, 12.0, step_m=2.0)
# 첫 세그먼트 직선부(원호 진입 전) 스텝 경사는 모두 10% 이어야 한다.
for i in range(len(poly) - 1):
if not (math.isinf(radii[i]) and math.isinf(radii[i + 1])):
continue # 원호 구간 제외
x1, y1, z1 = poly[i]
x2, y2, z2 = poly[i + 1]
hd = math.hypot(x2 - x1, y2 - y1)
if hd < 0.5 or y1 > 1.0 or y2 > 1.0:
continue # 첫 세그먼트(y=0 선상)만 검사
g = (z2 - z1) / hd
assert abs(g - 0.10) < 0.005, f"직선부 경사 {g:.4f} 가 설계경사 10% 에서 벗어남"
print("[ok] tangent sections keep the constant design grade")
def _valid_points_from_cost_surface(n=2):
from utils.route_solver import _build_cost_surface
td = INSTANCE_DIR / PROJECT_ID / "terrain_models"
x_sub, y_sub, z_sub, valid_sub, *_ = _build_cost_surface(td, FILTER_KEY, "dtm", True)
ys, xs = np.where(valid_sub)
picks = np.linspace(0, len(ys) - 1, n, dtype=int)
return [{"x": float(x_sub[xs[i]]), "y": float(y_sub[ys[i]]), "z": 0.0} for i in picks]
def test_integration_real_terrain():
"""실제 샘플 지형 통합 테스트 (데이터 없으면 스킵).
경로가 성립하면: 정속경사 구간이 8~14%(연결 세그먼트 제외 창) 이내이고
곡선반경 위반이 없어야 한다. 지형 제약상 경로가 성립하지 않을 수도
있으므로 '탐색 실패' 는 실패로 치지 않고 사유만 출력한다.
"""
if not DTM_PATH.exists():
print("[skip] 샘플 DTM 없음 → 통합 테스트 생략")
return
p = _valid_points_from_cost_surface(2)
points = {"bp": p[0], "ep": p[1], "cp": [], "ap": [], "fp": []}
options = {"grade_class": "trunk", "min_curve_radius_m": 12.0, "paved": False}
try:
result = solve_ridge_valley_route(
project_id=PROJECT_ID, filter_key=FILTER_KEY, smooth=True,
points_data=points, options=options, instance_dir=INSTANCE_DIR,
)
except ValueError as exc:
print(f"[skip] 이 지형/점 배치에서는 정속경사 경로 불성립: {exc}")
return
assert result["polyline"], "성공 시 polyline 이 비어있으면 안 된다"
assert result["metrics"]["length_m"] > 0
assert result["metrics"]["curve_violations"] == 0, "fillet 선형은 반경 위반이 없어야 한다"
# 지능선-지계곡 정속경사 구간(연결 세그먼트 제외 판별이 어려워 상한만 확인)
for seg in result["constant_grade_segments"]:
g = abs(seg["grade_pct"]) / 100.0
assert g <= config.ROUTE_ALT_MAX_GRADE + config.ROUTE_ALT_GRADE_TOLERANCE + 1e-6, (
f"정속경사 구간 {seg} 가 상한을 초과"
)
print(f"[ok] integration: length={result['metrics']['length_m']}m, "
f"segments={len(result['constant_grade_segments'])}")
def test_max_uphill_downhill_options_are_applied():
"""UI 의 '최대 오르막'/'최대 내리막' 옵션이 신규 알고리즘에도 반영되어야 한다.
conditions_snapshot 에 그대로 반영되는지, 그리고 오르막 한계를 8%(= 하한과 동일)로
좁히면 지형에서 성립 가능한 정속경사 세그먼트가 없어 탐색이 실패(ValueError)하는지로
간접 검증한다 (최대 오르막이 실제로 그래프 구성에 쓰이지 않으면 이 테스트가 실패한다)."""
if not DTM_PATH.exists():
print("[skip] 샘플 DTM 없음 → 옵션 반영 테스트 생략")
return
p = _valid_points_from_cost_surface(2)
points = {"bp": p[0], "ep": p[1], "cp": [], "ap": [], "fp": []}
# 1) conditions_snapshot 반영 확인 (탐색 성공/실패 여부와 무관하게 확인 가능하도록
# 널널한 값을 사용).
options_wide = {
"grade_class": "trunk", "min_curve_radius_m": 12.0, "paved": False,
"max_uphill_grade": 0.12, "max_downhill_grade": 0.13,
}
try:
result = solve_ridge_valley_route(
project_id=PROJECT_ID, filter_key=FILTER_KEY, smooth=True,
points_data=points, options=options_wide, instance_dir=INSTANCE_DIR,
)
snap = result["conditions_snapshot"]
assert abs(snap["max_uphill_grade_pct"] - 12.0) < 1e-6
assert abs(snap["max_downhill_grade_pct"] - 13.0) < 1e-6
print("[ok] conditions_snapshot reflects max_uphill_grade/max_downhill_grade options")
except ValueError:
print("[skip] 이 지형/점 배치에서는 12~13% 창으로 경로가 성립하지 않아 반영 여부만 로그로 확인")
# 2) 한계를 하한(8%)까지 좁히면 정속경사 세그먼트가 사실상 없어 탐색이 실패해야 한다
# (기본 14% 로는 성립하던 경로가 옵션에 따라 달라짐을 보여주는 회귀 방지 테스트).
options_narrow = {
"grade_class": "trunk", "min_curve_radius_m": 12.0, "paved": False,
"max_uphill_grade": 0.08, "max_downhill_grade": 0.08,
}
try:
solve_ridge_valley_route(
project_id=PROJECT_ID, filter_key=FILTER_KEY, smooth=True,
points_data=points, options=options_narrow, instance_dir=INSTANCE_DIR,
)
print("[ok] narrow 8% window still found a route on this terrain (also valid)")
except ValueError as exc:
assert "8" in str(exc), "실패 사유 메시지에 경사 한계가 언급되어야 한다"
print("[ok] narrowing max_uphill/downhill to 8% changes solver feasibility")
if __name__ == "__main__":
test_segment_feasible_constant_grade_plane()
test_segment_rejected_outside_grade_window()
test_segment_rejected_by_terrain_deviation()
test_segment_blocked_by_forbidden_circle()
test_resolve_grade_bounds_defaults()
test_resolve_grade_bounds_lower_bound_same_mechanism_as_upper()
test_turn_angle()
test_fillet_alignment_respects_min_radius()
test_fillet_alignment_keeps_constant_grade_on_tangents()
test_integration_real_terrain()
test_max_uphill_downhill_options_are_applied()
print("\n모든 route_solver_ridgevalley 테스트 통과")