diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py index 9a7571509..1f7e2d3df 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -37,6 +37,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import ( COST_LENGTH_M, ) from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth +from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import widen from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( CROSSING_BAND, OBJECTIVES, @@ -106,9 +107,9 @@ def _trace( traced: dict[tuple, Route], **kw: Any, ) -> Route: - # 큰 R(PLAN 328) — 1단 판(탐색 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤)이 위반이 - # 최소 R 판(노드마다 곡선 · 옛 규칙)보다 많지 않으면 그 판. 둘 다 화면이 [확인] 때 같은 선으로 - # 다시 짜는 꼴. + # 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 1단 판(탐색 경로를 + # 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 · 옛 규칙) + # 보다 많지 않은 첫 판. 셋 다 화면이 [확인] 때 같은 선으로 다시 짜는 꼴. # 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로). key = (tuple(nodes), tuple(sorted(kw.items()))) if key not in traced: @@ -118,7 +119,7 @@ def _trace( least = (floor, measure(floor, **kw)) path = densify_route(nodes, TRACE_DENSIFY_M) fitted = build_planned_polyline(path, min_radius_m=min_radius_m) - larger = (fitted,) + larger = (widen(fitted, path, min_radius_m), fitted) traced[key] = next( ( route diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py new file mode 100644 index 000000000..f425eaa70 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py @@ -0,0 +1,110 @@ +"""추천 노선 큰 R — 2단(PLAN 328-2 · 요구 224-4 「R 은 클수록 좋음」). + +`B05_Profile_Engine_RouteInitial_Pool._trace` 가 부름. 예상노선 추적(전처리 초기값)은 안 거침. + +1단(`_trace`)이 예정노선 규칙(단순화 · 같은 방향 묶음 · R 맞춤)으로 짠 선을 받아, 곡선마다 R 을 +[맞춘 R, 접선 자리 ÷ tan(IA/2)] 안에서 탐색 경로 점과 가장 먼 거리 ≤ `ROUTE_RECOMMEND_MAX_GAP_M` +인 가장 큰 값(1 m 내림)으로 고른다. 맞는 값이 없으면 맞춘 R 그대로. + +고른 R 은 편집 갈래(`simplify=False` · `radii`)로 못박아 다시 짠다 — 화면이 곡선을 교각점 하나로 +펴서(`flattenServerPlan`) [확인]이 다시 짜는 것과 같은 입력이라 저장 뒤 R 이 응답 R 과 같다. +""" + +from __future__ import annotations + +import math + +import numpy as np +from shapely.geometry import LineString, Point + +from common_util.common_util_route_polyline import ( + PlannedPolyline, + _inner_angle_deg, + build_planned_polyline, +) +from config.config_system_design import ROUTE_RECOMMEND_MAX_GAP_M + +Xy = tuple[float, float] + + +def _flat(outline: PlannedPolyline) -> tuple[list[Xy], list[bool], list[float | None]]: + """곡선 묶음을 교각점 하나로 편 노드 · 곡선 켬 · R — 화면 `flattenServerPlan` 과 같은 꼴.""" + starts = {curve.node_first: curve for curve in outline.curves} + inside = {i for c in outline.curves for i in range(c.node_first + 1, c.node_last + 1)} + points: list[Xy] = [] + flags: list[bool] = [] + radii: list[float | None] = [] + for index, node in enumerate(outline.nodes): + if index in inside: + continue + curve = starts.get(index) + points.append(curve.apex if curve else (node.x, node.y)) + flags.append(curve is not None) + radii.append(round(curve.radius_m, 2) if curve else None) + return points, flags, radii + + +def _cross(u: np.ndarray, v: np.ndarray) -> np.ndarray: + return u[..., 0] * v[..., 1] - u[..., 1] * v[..., 0] + + +def _gap(samples: np.ndarray, before: Xy, apex: Xy, after: Xy, radii: np.ndarray) -> np.ndarray: + """R 마다 점들이 「직선 · 원호 · 직선」(앞뒤 직선은 절반까지)에서 가장 먼 거리(m).""" + a = np.asarray(apex, dtype=float) + p = samples - a # (S, 2) 교각점 기준 + units = [(np.asarray(end, dtype=float) - a) for end in (before, after)] + halves = [float(np.hypot(*u)) / 2 for u in units] + units = [u / (2 * h) for u, h in zip(units, halves)] + inner = math.radians(_inner_angle_deg(before, apex, after)) + tangent = radii[:, None] * math.tan((math.pi - inner) / 2) # (M, 1) + legs = [] + for unit, half in zip(units, halves): + along = p @ unit + side = np.abs(_cross(unit, p)) + legs.append(np.hypot(side, np.maximum(0.0, np.maximum(tangent - along, along - half)))) + bisector = (units[0] + units[1]) / np.hypot(*(units[0] + units[1])) + center = (radii / math.sin(inner / 2))[:, None, None] * bisector # (M, 1, 2) + starts = [tangent[..., None] * unit - center for unit in units] # 중심 → 접선점 + q = p[None] - center # (M, S, 2) 중심 → 점 + turn = _cross(starts[0], starts[1]) + within = (_cross(starts[0], q) * turn >= 0) & (_cross(q, starts[1]) * turn >= 0) + arc = np.where(within, np.abs(np.hypot(q[..., 0], q[..., 1]) - radii[:, None]), np.inf) + return np.minimum(np.minimum(legs[0], legs[1]), arc).max(axis=1) + + +def widen( + outline: PlannedPolyline, + path: list[Xy], + min_radius_m: float, + max_gap_m: float = ROUTE_RECOMMEND_MAX_GAP_M, +) -> PlannedPolyline: + """곡선마다 허용 이탈 안 가장 큰 R(1 m 내림)로 못박아 다시 짠 선. 곡선이 없으면 그대로.""" + if not outline.curves: + return outline + points, flags, radii = _flat(outline) + line = LineString(path) + coords = np.asarray(path, dtype=float) + along = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(coords, axis=0).T))]) + for index in range(1, len(points) - 1): + if not flags[index]: + continue + before, apex, after = points[index - 1], points[index], points[index + 1] + half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2) + if half_tan <= 1e-9: + continue + room = min(math.dist(before, apex), math.dist(apex, after)) / 2 / half_tan + candidates = np.arange(math.floor(room), float(radii[index]), -1.0) # 큰 것부터 + # 이 곡선이 쓰는 자리 = 앞뒤 직선 가운데점 사이 — 그 사이 탐색 경로 점만 잼 + ends = [ + line.project(Point((apex[0] + end[0]) / 2, (apex[1] + end[1]) / 2)) + for end in (before, after) + ] + samples = coords[(along >= min(ends)) & (along <= max(ends))] + if not candidates.size or not len(samples): + continue + ok = candidates[_gap(samples, before, apex, after, candidates) <= max_gap_m] + if ok.size: + radii[index] = float(ok[0]) + return build_planned_polyline( + points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii + ) diff --git a/config/config_system_design.py b/config/config_system_design.py index 13fb522ef..4e7c43d93 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -38,6 +38,8 @@ ROUTE_DIRECT_LINK_CELL_FACTOR = float(os.getenv("ROUTE_DIRECT_LINK_CELL_FACTOR", # 예정노선을 자동 체인에 넘기기 전, 직결 문턱의 이 비율까지 정점 간격을 좁힌다. # 문턱과 정확히 같게 두면 부동소수 오차 한 번에 탐색으로 넘어가 노선이 바뀐다. ROUTE_PLANNED_DENSIFY_SAFETY = float(os.getenv("ROUTE_PLANNED_DENSIFY_SAFETY", "0.9")) +# 추천 노선 큰 R — 곡선을 키울 때 탐색 경로에서 벗어나도 되는 가장 먼 거리(m) · 탐색 칸 하나 +ROUTE_RECOMMEND_MAX_GAP_M = float(os.getenv("ROUTE_RECOMMEND_MAX_GAP_M", "4.0")) # 임도 종류 — 현행 규칙(별표2)의 3종. `branch`(지선)는 규칙에서 폐지됐으나 기존 # 저장분이 남아 있어 값으로는 계속 받는다(화면 선택지에서는 뺀다, 2026-08-19). ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch") diff --git a/resources/tester/test_224_4_recommend_radius.py b/resources/tester/test_224_4_recommend_radius.py new file mode 100644 index 000000000..befa3dae8 --- /dev/null +++ b/resources/tester/test_224_4_recommend_radius.py @@ -0,0 +1,156 @@ +"""추천 노선 큰 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 + ]