Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
157 lines
6.3 KiB
Python
157 lines
6.3 KiB
Python
"""추천 노선 큰 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
|
|
]
|