feat(B05추천): 328-2 추천 노선 큰 R — 곡선마다 [맞춘 R, 접선 자리 ÷ tan(IA/2)] 안 탐색 경로 이탈 ≤ 4 m 인 가장 큰 R(1 m 내림) · 못박아 다시 짬(편 노드 · [확인] 재구성과 같은 선) · 허용 이탈 상수 · test_224_4_recommend_radius
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
]
|
||||
Reference in New Issue
Block a user