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 테스트 통과")