- 균형 계획선 함수(서버 단독) — 종단기울기 상한 · 종단곡선(대수차) 지키며 B06 횡단 단면적으로 절 · 성토 균형 - 토공 최소 · 공사비 어림 점수를 균형 종단 토공(절 + 성 + 반입 + 사토)으로 - 추천 창에 균형 종단 한 줄 · 종단 그림 · 셈한 후보 표 · 지난 초 표시 - 균형 종단은 캐시에 안 적음 · 확정 체인 종단 규칙은 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
119 lines
5.0 KiB
Python
119 lines
5.0 KiB
Python
"""노선 추천 후보의 균형 종단(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 %)에서 균형 계획선이 종단기울기 상한을 지키고,
|
|
절(다짐 환산) · 성토가 허용(10 %) 안에서 맞고, 시 · 종점은 지반고에 붙는다. 토공 최소 갈래는
|
|
균형 종단 토공(절 + 성 + 반입 + 사토)으로 고른다. 균형 종단은 화면 캐시에 안 적힌다.
|
|
"""
|
|
|
|
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
|
|
assert abs(profile["imbalance_pct"]) <= profile["tolerance_pct"]
|
|
# 반입 − 사토 = 성토 − 절토(다짐 환산)
|
|
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"]["borrowM3"] == 0 and result["parsed"]["spoilM3"] == 39.9
|
|
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 "반입 0㎥" in result["line"] and "덮개 모자란 관 1/2" in result["line"]
|
|
assert result["svg"].count("<path") == 2
|
|
assert '<th scope="row">토공 최소</th>' in result["table"]
|