"""노선 추천 후보의 균형 종단(PLAN 63-4) — `B05_Profile_Engine_RouteInitial_Profile` 시험. ./venv/Scripts/python.exe -m pytest resources/tester/test_63_4_route_profile.py -q 합성 지형(노선 따라 ±20 % 물결 · 옆경사 50 %)에서 균형 계획선이 종단기울기 상한을 지키고, 누가토량 끝값(절 다짐 − 성)이 ±500㎥ 안에서 맞고, 시 · 종점은 지반고에 붙는다. 토공 최소 갈래는 균형 종단 토공(절 + 성 + 반입 + 사토)으로 고른다. 균형 종단은 화면 캐시에 안 적힌다. """ import json import os import subprocess import numpy as np import pytest from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import ( LEVEL_STEP_M, cover_shortfall, profile_earth, route_profile, ) from B05_Profile.B05_Profile_Engine_RouteInitial_Search import Terrain HERE = os.path.dirname(os.path.abspath(__file__)) GRADE = 0.14 ROUTE = [(50.0, 200.0), (350.0, 200.0)] @pytest.fixture(scope="module") def wavy() -> Terrain: x = np.arange(0.0, 402.0, 2.0) y = np.arange(100.0, 302.0, 2.0) xx, yy = np.meshgrid(x, y) z = 100.0 + 6.0 * np.sin(xx / 30.0) + 0.5 * (yy - 200.0) return Terrain(x, y, z, np.ones(xx.shape, dtype=bool)) @pytest.fixture(scope="module") def profile(wavy: Terrain) -> dict: return route_profile(ROUTE, wavy, max_grade=GRADE, design_speed=20) def test_상한을_지키고_지반_기울기는_넘는다(profile: dict) -> None: stations = np.asarray(profile["stations_m"]) plan = np.asarray(profile["plan_m"]) ground = np.asarray(profile["ground_m"]) grades = np.abs(np.diff(plan) / np.diff(stations)) assert grades.max() <= GRADE + 1e-6 assert profile["max_grade_pct"] <= profile["grade_limit_pct"] + 1e-6 # 지반을 그대로 따르면 상한을 넘는 지형 — 계획선이 지반에서 떨어져야 함 assert (np.abs(np.diff(ground) / np.diff(stations)) > GRADE).any() assert profile["max_cut_depth_m"] > 0.5 and profile["max_fill_height_m"] > 0.5 def test_절성토_균형은_허용_안(profile: dict) -> None: assert profile["balanced"] is True # 누가토량 끝값(절토 다짐 − 성토)이 ±500㎥ 안(PLAN 78) assert profile["mass_tolerance_m3"] == 500.0 assert abs(profile["mass_end_m3"]) <= profile["mass_tolerance_m3"] net = profile["cut_compacted_m3"] - profile["fill_m3"] assert profile["mass_end_m3"] == pytest.approx(net, abs=0.2) # 반입 − 사토 = 성토 − 절토(다짐 환산) net = profile["fill_m3"] - profile["cut_compacted_m3"] assert abs((profile["borrow_m3"] - profile["spoil_m3"]) - net) <= 0.2 assert min(profile["borrow_m3"], profile["spoil_m3"]) == 0.0 assert profile["cut_m3"] > 0 and profile["fill_m3"] > 0 def test_시종점은_지반고(profile: dict) -> None: for end in (0, -1): assert abs(profile["plan_m"][end] - profile["ground_m"][end]) <= LEVEL_STEP_M / 2 + 1e-6 def test_대수차_넘침_수는_계획선과_맞음(profile: dict) -> None: plan = np.asarray(profile["plan_m"]) grades = np.diff(plan) / np.diff(profile["stations_m"]) over = int((np.abs(np.diff(grades)) > profile["curve_delta_max_pct"] / 100.0 + 1e-6).sum()) assert over == profile["curve_over"] assert profile["curve_delta_max_pct"] == pytest.approx(5.0) # 20 m 변화점 → 생략 한도만 def test_토공_최소_점수는_균형_종단(profile: dict) -> None: metrics = {"cut_m3": 10.0, "fill_m3": 20.0} assert GOALS["earthwork"](metrics) == (30.0,) metrics["profile"] = profile assert GOALS["earthwork"](metrics) == (profile_earth(profile),) expected = profile["cut_m3"] + profile["fill_m3"] + profile["borrow_m3"] + profile["spoil_m3"] assert profile_earth(profile) == pytest.approx(expected) metrics["profile"] = {"error": "노선이 짧아 종단을 세울 수 없습니다."} assert GOALS["earthwork"](metrics) == (30.0,) def test_덮개_모자란_관만_센다(profile: dict) -> None: on_route = (200.0, 200.0, 99.0) # 여유 99 m — 반드시 모자람 shallow = (120.0, 201.0, -99.0) # 여유 음수 — 늘 넉넉 far = (200.0, 260.0, 99.0) # 노선에서 60 m — 이 노선 관이 아님 assert cover_shortfall(profile, ROUTE, [on_route, shallow, far]) == { "pipes_on_route": 2, "cover_short": 1, } def test_균형_종단은_캐시에_안_남는다() -> None: proc = subprocess.run( ["node", os.path.join(HERE, "helper_b05_routeedit_initial.cjs")], capture_output=True, text=True, encoding="utf-8", timeout=60, ) assert proc.returncode == 0, proc.stderr result = json.loads(proc.stdout)["balance63"] assert result["parsed"]["planM"] == [100, 101.5, 101] assert "balance" not in result["cached"] assert result["cached"]["metrics"]["cutM3"] is not None # 비교값은 그대로 캐시 assert result["missing"] is None and result["broken"] is None assert "반입" not in result["line"] and "사토" not in result["line"] assert "덮개 모자란 관 1/2" in result["line"] assert "누가토량 -40㎥(허용 ±500㎥)" in result["line"] assert result["svg"].count("토공 최소' in result["table"] assert '누가토량' in result["table"] and "반입" not in result["table"]