"""추천 노선 큰 R(PLAN 328 · 요구 224-4) — 1단 R 맞춤 · 2단 허용 이탈 안 가장 큰 R. ./venv/Scripts/python.exe -m pytest resources/tester/test_224_4_recommend_radius.py -q · 네 갈래(영선 · 토공 최소 · 기울기 최소 · 비중) R ≥ 최소(모자라면 위반) · 하나 이상 큼. · 위반 ≤ 같은 노드로 짠 최소 R 판(노드마다 곡선 · 옛 규칙) — 묶음의 잰 노선 전부. · 예상노선 추적은 전처리 초기값 그대로. · 2단 = 탐색 경로 이탈 ≤ 허용(4 m) · 1 m 더 키우면 넘음 · [확인] 재구성 뒤 같은 R. """ import math import numpy as np import pytest from shapely.geometry import LineString, Point import B05_Profile.B05_Profile_Engine_RouteInitial as engine from B05_Profile.B05_Profile_Engine_RouteInitial import Terrain, generate_initial_route from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, widen from common_util.common_util_route_polyline import build_planned_polyline from config.config_system_design import ROUTE_RECOMMEND_MAX_GAP_M GRADE = 0.14 RADIUS = 12.0 SHOWN = ["gradeline", "earthwork", "gentle", "weighted"] def _line() -> list[tuple[float, float]]: return [(float(x), 200.0) for x in np.arange(50.0, 752.0, 4.0)] @pytest.fixture(scope="module") def runs() -> dict: """가운데 언덕(40 m) — 곧은 예상노선이 꼭대기를 넘어 갈래마다 돌아감. 묶음 · 잼도 붙잡음.""" x = np.arange(0.0, 802.0, 2.0) y = np.arange(0.0, 402.0, 2.0) xx, yy = np.meshgrid(x, y) z = 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2) terrain = Terrain(x, y, z, np.ones(xx.shape, dtype=bool)) caught: dict = {} real = engine.build_pool def spy(graph, follow, max_grade, min_radius_m, measure): caught["measure"] = measure caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure) return caught["pool"] engine._POOLS.clear() engine.build_pool = spy try: results = { mode: generate_initial_route( _line(), terrain, [], mode=mode, weights=None, max_grade=GRADE, min_radius_m=RADIUS ) for mode in [*SHOWN, "follow"] } finally: engine.build_pool = real engine._POOLS.clear() return {"results": results, **caught} def test_shown_branches_radius_at_least_min_and_some_larger(runs: dict) -> None: radii = [] for mode in SHOWN: for curve in runs["results"][mode]["curves"]: assert curve["radius_m"] >= RADIUS - 1e-6 or curve["violations"], mode radii.append(curve["radius_m"]) assert max(radii) > RADIUS + 1.0 def test_violations_not_above_min_radius_version(runs: dict) -> None: measure = runs["measure"] for (nodes, kw), (_, metrics) in runs["pool"].traced.items(): floor = build_planned_polyline( list(nodes), min_radius_m=RADIUS, simplify=False, curve_flags=[True] * len(nodes) ) least = measure(floor, **dict(kw))["over_limit"]["total"] assert metrics["over_limit"]["total"] <= least def test_follow_unchanged(runs: dict) -> None: initial = build_planned_polyline(_line(), min_radius_m=RADIUS) result = runs["results"]["follow"] assert result["planned"] == [[round(x, 4), round(y, 4)] for x, y in initial.vertices] assert result["curves"] == [curve.as_dict() for curve in initial.curves] def _bend(radius: float) -> list[tuple[float, float]]: """직선 100 m → 반지름 `radius` 원호 90° → 직선 100 m 를 2 m 간격 점으로.""" points = [(float(x), 0.0) for x in np.arange(-100.0, 0.0, 2.0)] steps = int(radius * math.pi / 2 / 2.0) points += [ (radius * math.sin(a), radius - radius * math.cos(a)) for a in np.linspace(0.0, math.pi / 2, steps) ] points += [(radius, radius + float(y)) for y in np.arange(2.0, 102.0, 2.0)] return points def _window_gap(path, outline, curve) -> float: """곡선 앞뒤 직선 가운데점 사이 경로 점이 노선에서 가장 먼 거리.""" line = LineString(path) drawn = LineString(outline.vertices) apex = curve.apex nodes = [(node.x, node.y) for node in outline.nodes] at = nodes.index(min(nodes, key=lambda n: math.dist(n, apex))) ends = [ line.project(Point((apex[0] + n[0]) / 2, (apex[1] + n[1]) / 2)) for n in (nodes[at - 1], nodes[at + 1]) ] return max( drawn.distance(Point(p)) for p in path if min(ends) <= line.project(Point(p)) <= max(ends) ) def test_widen_largest_radius_within_gap() -> None: path = _bend(80.0) fitted = build_planned_polyline(path, min_radius_m=RADIUS) wide = widen(fitted, path, RADIUS) assert len(wide.curves) == 1 curve = wide.curves[0] assert curve.radius_m >= fitted.curves[0].radius_m assert curve.radius_m == math.floor(curve.radius_m) # 1 m 내림 assert curve.radius_m > 40.0 assert _window_gap(path, wide, curve) <= ROUTE_RECOMMEND_MAX_GAP_M + 1e-6 # 1 m 더 키우면 허용 이탈을 넘음(가장 큰 값) — 셈 자(shapely)로 따로 잼 points, flags, radii = _flat(wide) seat = flags.index(True) radii[seat] = curve.radius_m + 1.0 bigger = build_planned_polyline( points, min_radius_m=RADIUS, simplify=False, curve_flags=flags, radii=radii ) assert _window_gap(path, bigger, bigger.curves[0]) > ROUTE_RECOMMEND_MAX_GAP_M def test_widen_survives_confirm_rebuild() -> None: """화면이 펴서 보내는 꼴(교각점 · 곡선 켬 · R)로 [확인]이 다시 짜도 같은 R.""" path = _bend(60.0) + [(60.0 + float(x), 160.0) for x in np.arange(2.0, 120.0, 2.0)] wide = widen(build_planned_polyline(path, min_radius_m=RADIUS), path, RADIUS) points, flags, radii = _flat(wide) again = build_planned_polyline( points, min_radius_m=RADIUS, simplify=False, curve_flags=flags, radii=radii ) assert [c.radius_m for c in again.curves] == [c.radius_m for c in wide.curves] assert again.vertices == wide.vertices def test_widen_keeps_fitted_when_gap_already_over() -> None: """허용 이탈 0 이면 키울 R 이 없어 맞춘 R 그대로.""" path = _bend(80.0) fitted = build_planned_polyline(path, min_radius_m=RADIUS) same = widen(fitted, path, RADIUS, max_gap_m=0.0) assert [round(c.radius_m, 2) for c in same.curves] == [ round(c.radius_m, 2) for c in fitted.curves ]