"""406 추천 초기노선 같은 쪽 곡선 합치기 · 직접 잼 — `B05_Profile_Engine_RouteInitial_Merge`. ./venv/Scripts/python.exe -m pytest resources/tester/test_406_1_route_merge.py -q · 같은 쪽으로 도는 이웃 곡선 둘 → 바깥 두 직선 교점 하나(곡선 하나) · S자는 안 합침. · 막힘 — 지반 기울기(골짜기) · 복도 밖(지형 없음) · 계류 버퍼 안 나란히 · 노선 전체 잣대. · 큰 R(`widen`) 직접 잼 = 평지면 이탈 한도(4 m) 판보다 큼. · 406-4 — 측점에 놓을 수 없는 쌍은 처음부터 안 합침 · 언덕에서 세 갈래가 다른 선. """ import numpy as np from shapely.geometry import LineString, Point from B05_Profile.B05_Profile_Engine_RouteInitial import generate_initial_route from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, merge_same_side from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, widen from B05_Profile.B05_Profile_Engine_RouteInitial_Search import SearchGrid, Terrain from B05_Profile.B05_Profile_Engine_RouteInitial_Station import _Reference from common_util.common_util_route_polyline import _line_intersection, build_planned_polyline RADIUS = 12.0 GRADE = 0.14 SAME = [(20.0, 20.0), (150.0, 20.0), (230.0, 70.0), (260.0, 180.0)] # 둘 다 왼쪽으로 돎 S_CURVE = [(20.0, 20.0), (150.0, 20.0), (230.0, 70.0), (360.0, 80.0)] # 왼쪽 → 오른쪽 FLAT = {"over_limit": {"total": 0, "grade": 0}, "max_grade_pct": 0.0, "cut_m3": 0.0, "fill_m3": 0.0} def _outline(points): return build_planned_polyline( points, min_radius_m=RADIUS, simplify=False, curve_flags=[True] * len(points) ) def _guard(points, z=None, valid=None, streams=(), stream_offset_m=100.0): x = np.arange(0.0, 402.0, 2.0) y = np.arange(0.0, 262.0, 2.0) xx, yy = np.meshgrid(x, y) terrain = Terrain( x, y, np.zeros(xx.shape) if z is None else z(xx, yy), np.ones(xx.shape, dtype=bool) if valid is None else valid(xx, yy), ) grid = SearchGrid(points, terrain, list(streams), stream_offset_m=stream_offset_m) return Guard(grid, GRADE) def _distance(outline): line = LineString(outline.vertices) return np.vectorize(lambda px, py: line.distance(Point(px, py))) def _merge(points, guard, measure=lambda line: FLAT): outline = _outline(points) return merge_same_side((outline, FLAT), guard, RADIUS, measure)[0] def test_same_side_pair_merges_to_outer_intersection() -> None: merged = _merge(SAME, _guard(SAME)) assert len(merged.curves) == 1 cross = _line_intersection(*SAME) assert merged.curves[0].apex == (round(cross[0], 4), round(cross[1], 4)) assert merged.curves[0].radius_m > RADIUS def test_s_curve_keeps_straight() -> None: merged = _merge(S_CURVE, _guard(S_CURVE)) assert len(merged.curves) == 2 assert _flat(merged)[0] == _flat(_outline(S_CURVE))[0] def test_valley_earth_blocks() -> None: """옛 선 = 골짜기 바닥 · 합친 곡선은 옆 비탈을 올라 토공 가늠이 늘어 막힘(419 — 원지반 기울기는 참고만이라 기울기로는 안 막음 · 넘는 만큼은 잼에 남음).""" distance = _distance(_outline(SAME)) guard = _guard(SAME, z=lambda xx, yy: 3.0 * distance(xx, yy)) assert len(_merge(SAME, guard).curves) == 2 cross = _line_intersection(*SAME) piece = guard.piece(SAME[0], cross, SAME[-1], 40.0) window = _Reference(_outline(SAME).vertices).window(piece) passed, (out, along, earth) = guard.judge(piece, window) assert not passed and not out and along == 0.0 and earth > 0.0 assert guard._look(piece)[0] > guard._look(window)[0] # 기울기는 참고로 잼 def test_outside_corridor_blocks() -> None: distance = _distance(_outline(SAME)) guard = _guard(SAME, valid=lambda xx, yy: distance(xx, yy) <= 4.0) assert len(_merge(SAME, guard).curves) == 2 def test_stream_buffer_blocks() -> None: """평지에서 합친 큰 곡선 가운데에 나란한 계류 — 그 곡선만 버퍼 안을 나란히 지남.""" merged = _merge(SAME, _guard(SAME)) arc = LineString(merged.vertices) middle = arc.interpolate(arc.project(Point(_line_intersection(*SAME)))) ahead = arc.interpolate(arc.project(middle) + 1.0) unit = np.subtract(ahead.coords[0], middle.coords[0]) stream = [tuple(np.add(middle.coords[0], unit * s)) for s in (-12.0, 12.0)] gap = LineString(_outline(SAME).vertices).distance(middle) assert gap > 5.0 guard = _guard(SAME, streams=[stream], stream_offset_m=0.5 * gap) original = _outline(SAME).vertices assert guard._look(original)[2] == 0.0 # 옛 선은 버퍼 밖 # 버퍼 안을 나란히 지나는 큰 R 은 막힘 — 버퍼를 비키는 작은 R 로 합치거나 안 합침 kept = _merge(SAME, guard) assert len(kept.curves) == 2 or kept.curves[0].radius_m < merged.curves[0].radius_m assert guard._look(kept.vertices)[2] == 0.0 def test_route_yardstick_blocks() -> None: """노선 전체 잣대(못 지킨 곳 · 절성토 +10 %)가 합치기 전보다 나빠지면 안 합침 · 최대 기울기는 참고만(419).""" steeper = {**FLAT, "max_grade_pct": 5.0} assert len(_merge(SAME, _guard(SAME), measure=lambda line: steeper).curves) == 1 over = {**FLAT, "over_limit": {"total": 1, "grade": 0}} assert len(_merge(SAME, _guard(SAME), measure=lambda line: over).curves) == 2 def test_widen_guard_larger_than_gap_limit() -> None: """곧은 두 직선이 직각으로 만나는 탐색 경로 — 이탈 4 m 면 R 이 못 커짐 · 평지 직접 잼은 큼.""" path = [(float(x), 20.0) for x in np.arange(20.0, 220.0, 2.0)] path += [(220.0, float(y)) for y in np.arange(20.0, 222.0, 2.0)] fitted = build_planned_polyline(path, min_radius_m=RADIUS) gap = widen(fitted, path, RADIUS) direct = widen(fitted, path, RADIUS, guard=_guard(path)) assert direct.curves[0].radius_m > gap.curves[0].radius_m + 20.0 def test_406_4_unplaceable_pair_stays() -> None: """합친 곡선 자리만 지형이 있음(그 둘레 3 m) — 가운데 꼴은 지나도 IP 를 밀어 측점에 놓은 꼴이 복도 밖이면 처음부터 안 합침(합친 뒤 푸는 것 아님).""" merged = _merge(SAME, _guard(SAME)) keep = [LineString(merged.vertices), LineString(_outline(SAME).vertices)] near = np.vectorize(lambda px, py: min(line.distance(Point(px, py)) for line in keep)) guard = _guard(SAME, valid=lambda xx, yy: near(xx, yy) <= 3.0) cross = _line_intersection(*SAME) piece = guard.piece(SAME[0], cross, SAME[-1], merged.curves[0].radius_m) assert guard._look(piece)[3] # 가운데 꼴은 복도 안 assert len(_merge(SAME, guard).curves) == 2 def test_406_4_branches_differ_on_hill() -> None: """제약을 지키는 길이 있는 언덕 — 영선 · 토공 최소 · 기울기 최소가 다른 선(후보 거름 뒤에도).""" x = np.arange(0.0, 802.0, 2.0) y = np.arange(0.0, 402.0, 2.0) xx, yy = np.meshgrid(x, y) hill = Terrain( x, y, 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2), np.ones(xx.shape, dtype=bool), ) line = [(float(v), 200.0) for v in np.arange(50.0, 752.0, 4.0)] lines = { mode: LineString( generate_initial_route( line, hill, [], mode=mode, weights=None, max_grade=GRADE, min_radius_m=RADIUS )["planned"] ) for mode in ("gradeline", "earthwork", "gentle") } names = list(lines) for i, a in enumerate(names): for b in names[i + 1 :]: assert lines[a].hausdorff_distance(lines[b]) > 10.0, (a, b)