"""419 노선 추천 엔진 — 원지반 기울기 참고만 · 삼각 없음 · 측점 필수 · 합치기 멈춤 = 노선 토공. ./venv/Scripts/python.exe -m pytest resources/tester/test_419_1_recommend_engine.py -q · 못 지킨 곳 수 = R · 계류 · 측점(원지반 기울기는 목록 · `grade` 수에 참고로만). · 꺾임만(R 없는 교점) → 곡선(155° 이상도) 또는 교점 지움 · 측점 맞춤(직접 잼)도 꺾임만을 안 냄. · 합치기 멈춤 = 노선 토공 +10 % — 바뀐 측점 횡단 차이(`cross_earthwork_delta`)를 주면 그 값으로. · 종단 반영 켬 토공 고르기 = 줄 세운 위 3 개만 균형 종단 · 시종점 기울기 알림 · 맞추기 전 선 안 냄. """ import math import numpy as np import pytest from B05_Profile.B05_Profile_Engine_RouteInitial import ends_grade, generate_initial_route from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import ( TOTAL_EARTH_TOL, Guard, RouteEarth, merge_same_side, ) from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import SHORTLIST, choose, hard_limits from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, curve_every_kink from B05_Profile.B05_Profile_Engine_RouteInitial_Search import SearchGrid, Terrain from B05_Profile.B05_Profile_Engine_RouteInitial_Station import ( off_station_count, snap_curves_to_stations, ) from common_util.common_util_route_polyline import build_planned_polyline RADIUS = 12.0 SAME = [(20.0, 20.0), (150.0, 20.0), (230.0, 70.0), (260.0, 180.0)] def _kinks(outline) -> int: return sum(1 for flag in _flat(outline)[1][1:-1] if not flag) def _flat_terrain(width=802.0, height=402.0) -> Terrain: x = np.arange(0.0, width, 2.0) y = np.arange(0.0, height, 2.0) xx, _ = np.meshgrid(x, y) return Terrain(x, y, np.zeros(xx.shape), np.ones(xx.shape, dtype=bool)) def test_hard_limits_leave_grade_as_reference() -> None: metrics = { "over_limit": {"grade": 3, "radius": 1, "stream": 1, "total": 5}, "violations": [{"kind": "grade"}] * 3 + [{"kind": "radius"}, {"kind": "stream"}], } hard = hard_limits(metrics) assert hard["over_limit"] == {"grade": 3, "radius": 1, "stream": 1, "total": 2} assert hard["violations"] == metrics["violations"] # 목록엔 참고로 남음 def test_kink_becomes_curve_even_gentle() -> None: """내각 160° 꺾임만(곡선 끔) → 곡선(최소 R) · 155° 이상도 곡선 둠.""" points = [(0.0, 0.0), (100.0, 0.0), (200.0, 100.0 * math.tan(math.radians(20.0)))] kinked = build_planned_polyline( points, min_radius_m=RADIUS, simplify=False, curve_flags=[True, False, True] ) assert _kinks(kinked) == 1 fixed = curve_every_kink(kinked, RADIUS) assert _kinks(fixed) == 0 and len(fixed.curves) == 1 assert fixed.curves[0].radius_m >= RADIUS - 1e-6 and not fixed.curves[0].violations def test_kink_without_room_is_removed() -> None: """앞뒤 직선이 짧아 최소 R 이 안 드는 꺾임만 → 교점 지움(곧게).""" points = [(0.0, 0.0), (100.0, 0.0), (103.0, 2.0), (200.0, 2.0)] kinked = build_planned_polyline( points, min_radius_m=RADIUS, simplify=False, curve_flags=[True, True, False, True] ) fixed = curve_every_kink(kinked, RADIUS) assert _kinks(fixed) == 0 assert len(_flat(fixed)[0]) == 3 # 짧은 쪽 교점 하나 빠짐 def _zigzag(seed: int) -> list[tuple[float, float]]: rng = np.random.default_rng(seed) points, heading = [(60.0, 200.0)], 0.0 for _ in range(10): heading += float(rng.uniform(-0.6, 0.6)) step = float(rng.uniform(40.0, 80.0)) x, y = points[-1] points.append((x + step * math.cos(heading), y + step * math.sin(heading))) return points @pytest.mark.parametrize("seed", range(6)) def test_guard_snap_no_kinks_all_on_station(seed: int) -> None: """직접 잼 측점 맞춤(추천 후보) — 평지 · 꺾임만 섞인 선 → 꺾임만 0 · 측점 밖 0.""" points = _zigzag(seed) flags = [True] + [i % 3 != 1 for i in range(1, len(points) - 1)] + [True] before = build_planned_polyline(points, min_radius_m=RADIUS, simplify=False, curve_flags=flags) lo = np.min(points, axis=0) - 150.0 hi = np.max(points, axis=0) + 150.0 x = np.arange(lo[0], hi[0], 2.0) y = np.arange(lo[1], hi[1], 2.0) terrain = Terrain(x, y, np.zeros((len(y), len(x))), np.ones((len(y), len(x)), dtype=bool)) guard = Guard(SearchGrid(points, terrain, [], stream_offset_m=100.0), 0.14) after = snap_curves_to_stations(before, RADIUS, guard=guard) assert after is not None assert _kinks(after) == 0 assert off_station_count(after) == 0 FLAT = {"over_limit": {"total": 0, "grade": 0}, "max_grade_pct": 0.0} def _merge(earth_base: float, delta: float): """평지 같은 쪽 두 꺾임 — 노선 토공 `earth_base` · 합친 선 차이 `delta`(가짜 횡단 함수).""" outline = build_planned_polyline( SAME, min_radius_m=RADIUS, simplify=False, curve_flags=[True] * len(SAME) ) base = {**FLAT, "cut_m3": 0.0, "fill_m3": 0.0} # 띠 가늠은 안 씀 calls = [] def cross(before, after, window): calls.append(window) return delta guard = Guard(SearchGrid(SAME, _flat_terrain(402.0, 262.0), [], stream_offset_m=100.0), 0.14) earth = RouteEarth(lambda line: earth_base, cross) merged = merge_same_side((outline, base), guard, RADIUS, lambda line: base, earth)[0] return merged, calls def test_merge_stops_at_route_earth_tolerance() -> None: """합치기 판정 = 바뀐 측점 횡단 차이 — +10 % 안이면 합치고 넘으면 안 합침(엔진 상수).""" assert TOTAL_EARTH_TOL == pytest.approx(0.10) merged, calls = _merge(1000.0, 90.0) assert len(merged.curves) == 1 and calls s0, s1 = calls[0] assert 0.0 <= s0 < s1 # 바뀐 자리(앞 선 누가거리)만 넘김 blocked, calls = _merge(1000.0, 110.0) assert len(blocked.curves) == 2 and calls def _route(name: str, earth: float, station: int = 0): metrics = { "name": name, "cut_m3": earth, "fill_m3": 0.0, "cost_index": earth, "over_limit": {"total": station, "station": station}, } return (object(), metrics) def test_choose_profiles_only_shortlist() -> None: """종단 반영 켬 토공 = 노선 횡단 토공(줄 세움) 위 `SHORTLIST` 개만 균형 종단 · 그 사이에서 고름 · 줄 세움 = `earth`(켬 = 상한 누른 계획선 · 끔 = 지반고) · 없으면 띠 가늠.""" routes = [_route(f"r{i}", 100.0 + 10 * i) for i in range(12)] + [_route("off", 1.0, 1)] last = f"r{SHORTLIST - 1}" profiled = [] def profile(route): profiled.append(route[1]["name"]) # 균형 종단 토공은 순서가 바뀜 — 줄 세운 마지막이 가장 작음 · 밖은 더 작아도 안 봄 earth = 300.0 if route[1]["name"] == last else 900.0 route[1]["profile"] = {"cut_m3": earth, "fill_m3": 0, "borrow_m3": 0, "spoil_m3": 0} chosen = choose("earthwork", routes, True, profile) assert profiled == [f"r{i}" for i in range(SHORTLIST)] assert chosen[1]["name"] == last # 종단 끔 = 줄 세우기 값 그대로 · 종단 안 붙임 profiled.clear() assert choose("earthwork", routes, False, profile)[1]["name"] == "r0" and not profiled # 횡단 줄 세움이 띠 가늠과 다르면 그것을 따름(끔 = 지반고 횡단 하나로 고름) cross = {f"r{i}": 1000.0 - i for i in range(12)} def earth(route, capped): return cross.get(route[1]["name"], 0.0) + (0.0 if capped else 5.0) assert choose("earthwork", routes, False, None, earth)[1]["name"] == "r11" def test_ends_grade() -> None: x = np.arange(0.0, 402.0, 2.0) y = np.arange(0.0, 102.0, 2.0) xx, _ = np.meshgrid(x, y) slope = Terrain(x, y, 0.3 * xx, np.ones(xx.shape, dtype=bool)) assert ends_grade([(50.0, 50.0), (250.0, 50.0)], slope, 200.0) == pytest.approx(30.0) assert ends_grade([(50.0, 50.0), (250.0, 50.0)], slope, 600.0) == pytest.approx(10.0) @pytest.fixture(scope="module") def hill_runs() -> dict: 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), xx > -1) expected = [(float(v), 200.0 + 40.0 * math.sin(v / 60.0)) for v in np.arange(50.0, 752.0, 4.0)] return { mode: generate_initial_route( expected, hill, [], mode=mode, weights=None, max_grade=0.14, min_radius_m=RADIUS ) for mode in ("gradeline", "earthwork", "gentle", "weighted") } @pytest.mark.parametrize("mode", ["gradeline", "earthwork", "gentle", "weighted"]) def test_hill_branches_rules(hill_runs: dict, mode: str) -> None: """언덕 — 꺾임만 0 · 측점 밖 0 · 못 지킨 곳 수 = R · 계류 · 측점 · 맞추기 전 선 없음.""" result = hill_runs[mode] interior = result["nodes"][1:-1] assert interior and all(node["radius_m"] is not None for node in interior) # 삼각 0 assert result["off_station_curves"] == 0 over = result["metrics"]["over_limit"] assert over["total"] == over["radius"] + over["stream"] + over.get("station", 0) assert "unsnapped" not in result assert result["ends_over_limit"] is False and result["ends_grade_pct"] < 14.0 def _hill_expected(): 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), xx > -1) expected = [(float(v), 200.0 + 40.0 * math.sin(v / 60.0)) for v in np.arange(50.0, 752.0, 4.0)] return hill, expected def test_419_2_pick_independent_of_cache_order() -> None: """같은 입력 — 앞서 다른 갈래를 보였든(캐시 후보에 정밀 종단) 처음이든 같은 선(419-2).""" from B05_Profile.B05_Profile_Engine_RouteInitial import _POOLS hill, expected = _hill_expected() def run(mode): return generate_initial_route( expected, hill, [], mode=mode, weights=None, max_grade=0.14, min_radius_m=RADIUS ) _POOLS.clear() fresh = {mode: run(mode)["planned"] for mode in ("earthwork", "cost")} _POOLS.clear() for mode in ("gentle", "gradeline", "weighted", "cost", "earthwork", "gentle"): run(mode) again = {mode: run(mode)["planned"] for mode in ("earthwork", "cost")} # 캐시 후보에 정밀 종단이 안 남음 — 고르기는 늘 빠른 판끼리(정밀은 낼 사본에만) pool = next(iter(_POOLS.values())) routes = [*pool.candidates, *pool.searched.values()] assert not any((m.get("profile") or {}).get("exact") for _, m in routes) _POOLS.clear() assert again == fresh def test_419_3_applied_profile_holds_grade() -> None: """[적용] 종단 — 종단곡선을 넣으려 꼭짓점을 밀어도 직선 · 곡선 어디서나 균형 종단 상한 안.""" from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( AlignmentPolicy, build_curves, evaluate, ) from B05_Profile.B05_Profile_Engine_Grade_Profile import pass_through from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import held_pass_through policy = AlignmentPolicy.from_config( station_interval_m=20.0, max_grade_pct=14.0, curve_skip_delta_pct=5.0, paved=False ) s = np.arange(0.0, 401.0, 20.0) grades = [0.14, 0.14, -0.02, 0.14, 0.14, 0.03, -0.14, -0.14, 0.0, 0.14] grades += [0.10, 0.14, -0.05, -0.14, -0.14, 0.02, 0.14, 0.14, 0.0, -0.14] plan = np.concatenate([[100.0], 100.0 + np.cumsum(np.asarray(grades) * 20.0)]) fine = np.linspace(0.0, 400.0, 4001) def steepest(z): curves, _ = build_curves(s, z, policy) return float(np.abs(np.diff(evaluate(s, z, curves, fine)) / 0.1).max()) assert steepest(pass_through(s, plan, policy)) > 0.15 # 옛 맞춤 = 상한 넘음(재현) held = held_pass_through(s, list(s), list(plan), policy) assert steepest(held) <= 0.14 + 1e-5 assert steepest(np.round(held, 4)) <= 0.14 # 정본 0.1 mm 반올림 뒤도 상한 안 assert held[0] == pytest.approx(plan[0]) and held[-1] == pytest.approx(plan[-1])