fix(B05): 초기노선 추천 갈래가 한 노선으로 모이던 흠 — 갈래마다 제 목표 비교값으로 고름 · 예상노선 추적 = 전처리 초기값(PLAN 23-1)

- 원인: 가파른 지형(옆경사 평균 67 %)에서 4 m 링크의 14 %뿐이 상한 안 · 반지름 규칙까지 지키는 길이 없어 풂(상한 29 %) · 풂 탐색 벌점(1 m 당 500 × 넘은 비율)이 목표(1 m 당 평균 1)를 덮어 네 갈래가 「넘은 기울기 합 최소」 한 길로 · 탐색 목표(4 m 링크)와 비교값(측점 20 m · 절성토)이 어긋나 벌점만 낮춰도 순서가 안 맞음 · follow 는 이격 최소 탐색이라 초기값과 다름
- 후보 묶음(목표 비중 셋 × 벌점 50 · 5 + 센 벌점 기준 길 · 엄격하면 비중마다 상한 단계)을 한 번 짜고 갈래는 비교값으로 고름 — 토공 최소 = 절성토 합 최소 · 기울기 순한 = 최대(같으면 평균) 최소 · 비중 = 이격 · 절성토 · 기울기를 후보 범위로 정규화한 가중합 · 기준 길보다 못 지킨 곳이 많은 후보는 뺌
- follow = build_planned_polyline(예상노선) — planned_route_initial.csv 와 같은 선(1078.01 m) · 넘은 곳은 「예상노선 추적」 사유로 목록
- 탐색을 B05_Profile_Engine_RouteInitial_Search.py 로 나눔 · 상태 그래프 한 번만 짜고 탐색마다 간선만 거름 · 막는 기울기 이분 탐색은 BFS · 후보 묶음은 같은 입력이면 프로세스 메모리에서 다시 씀
- initial_route 를 B05_Profile_Router_RouteInitial.py 로 나눔(Router_Replan 712 → 630줄, 같은 router 에 등록) · 없는 프로젝트 500 → 404
- 시험 test_route_initial 20(추종 = 초기값 · 갈래별 목적 순서 · 랩탑_보조 실데이터 갈래 차이)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
This commit is contained in:
2026-09-29 19:51:50 +09:00
co-authored by Claude Opus 5.5
parent eb387d5f84
commit 77d3ffeee6
6 changed files with 746 additions and 470 deletions
+109 -2
View File
@@ -4,10 +4,15 @@
공통 제약(종단기울기 상한 · 최소 곡선반지름 · 계류 이격)은 넘지 못함 — 지형상 불가능할 때만
`metrics.violations` 에 측점 · 값 · 사유로 뜨고 `over_limit.total` 은 그 수와 같다.
예상노선 추적(follow)은 전처리 초기값과 같은 선 — 제약 탐색 없이 넘은 곳만 목록(PLAN 23-1).
갈래는 제 목표 비교값이 가장 좋은 후보 — 실프로젝트(랩탑_보조 · 읽기만)에서 갈래가 갈림.
"""
from pathlib import Path
import numpy as np
import pytest
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Engine_RouteInitial import (
MODES,
@@ -21,6 +26,9 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import (
write_initial_choice,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import grade_line, station_label
from common_util.common_util_route_polyline import build_planned_polyline
SEARCHED = [mode for mode in MODES if mode != "follow"]
GRADE = 0.14
RADIUS = 12.0
@@ -68,7 +76,7 @@ def hill() -> Terrain:
)
@pytest.mark.parametrize("mode", MODES)
@pytest.mark.parametrize("mode", SEARCHED)
def test_every_mode_keeps_grade_limit_on_hill(hill: Terrain, mode: str) -> None:
result = _run(hill, mode)
_kept(result)
@@ -81,6 +89,30 @@ def test_every_mode_keeps_grade_limit_on_hill(hill: Terrain, mode: str) -> None:
assert metrics["offset_max_m"] <= result["criteria"]["corridor_m"] + 4.0
def test_follow_is_initial_polyline_and_lists_violations(hill: Terrain) -> None:
"""추종 = 전처리 초기값 규칙(`build_planned_polyline` 기본값) — 언덕을 넘는 곳은 목록에."""
result = _run(hill, "follow")
initial = build_planned_polyline(_line(200.0), min_radius_m=RADIUS)
assert result["planned"] == [[round(x, 4), round(y, 4)] for x, y in initial.vertices]
assert result["nodes"] == [node.as_dict() for node in initial.nodes]
assert result["relaxed"] is False and result["terrain_bound_pct"] is None
grades = [v for v in result["metrics"]["violations"] if v["kind"] == "grade"]
assert grades and all("예상노선 추적" in v["reason"] for v in grades)
assert result["metrics"]["over_limit"]["total"] == len(result["metrics"]["violations"])
def _earth(result: dict) -> float:
return result["metrics"]["cut_m3"] + result["metrics"]["fill_m3"]
def test_each_branch_best_at_own_goal(hill: Terrain) -> None:
"""토공 최소 = 절성토 가장 적음 · 기울기 순한 = 최대 기울기 가장 낮음(같은 후보 묶음)."""
runs = {mode: _run(hill, mode) for mode in SEARCHED}
assert all(_earth(runs["earthwork"]) <= _earth(r) + 1e-6 for r in runs.values())
top = runs["gentle"]["metrics"]["max_grade_pct"]
assert all(top <= r["metrics"]["max_grade_pct"] + 1e-6 for r in runs.values())
def test_follow_stays_on_expected_where_allowed() -> None:
"""고른 경사(2 %) — 추종은 예상노선을 그대로(격자 칸 반 안쪽)."""
ramp = _terrain(lambda xx, yy: 0.02 * xx)
@@ -99,7 +131,7 @@ def test_stream_offset_is_hard() -> None:
"""평지 · 예상노선 20 m 옆을 나란히 흐르는 소하천 — 모든 갈래가 버퍼를 비켜 감."""
flat = _terrain(lambda xx, yy: np.zeros_like(xx))
stream = [[(0.0, 220.0), (800.0, 220.0)]]
for mode in MODES:
for mode in SEARCHED:
result = _run(flat, mode, streams=stream)
_kept(result)
assert result["metrics"]["over_limit"]["stream"] == 0
@@ -237,3 +269,78 @@ def test_request_accepts_criteria_overrides() -> None:
)
assert replan.initial is not None and replan.initial.criteria is not None
assert replan.initial.criteria.max_grade_pct == 12
# ── 실프로젝트(랩탑_보조) — 읽기만 · storage 가 없으면 건너뜀 ──────────────────────────
PROJECT = Path(__file__).resolve().parents[2] / "storage/1/3/936be972-11bc-46c2-8bf3-b15d8de7df0d"
SURFACE = PROJECT / "B05_Profile/route/cost_surface_classification_dtm_smooth.npz"
@pytest.fixture(scope="module")
def project_runs() -> dict:
"""네 갈래 결과 — 비용면 캐시 · 계류 · 예상노선을 읽기만(파일 안 씀). 기준 14 % · R12."""
if not SURFACE.is_file():
pytest.skip("랩탑_보조 storage 없음")
from pyproj import Transformer
from B04_PreProcess.B04_PreProcess_Router_Watershed import (
STREAM_FILE,
_load_features,
_reproject_features,
)
from common_util.common_util_crs import resolve_project_crs
from common_util.common_util_route_geometry import (
expected_route_csv_path,
read_planned_route_csv,
)
cached = np.load(SURFACE, allow_pickle=False)
terrain = Terrain(cached["x"], cached["y"], cached["z"], cached["valid_mask"])
to_metric = Transformer.from_crs("EPSG:4326", resolve_project_crs(PROJECT), always_xy=True)
features = _load_features(PROJECT / "B04_PreProcess" / "processed", STREAM_FILE)
streams = stream_lines(_reproject_features(features, to_metric))
route = read_planned_route_csv(expected_route_csv_path(PROJECT))
expected = [(float(v.x), float(v.y)) for v in route.vertices]
return {
mode: generate_initial_route(
expected,
terrain,
streams,
mode=mode,
weights=None,
max_grade=GRADE,
min_radius_m=RADIUS,
)
for mode in MODES
}
def test_project_follow_is_preprocess_initial(project_runs: dict) -> None:
"""추종 = 전처리 초기값 `planned_route_initial.csv`(1078 m) 그대로."""
from common_util.common_util_route_geometry import (
planned_route_initial_path,
read_planned_route_csv,
)
initial = read_planned_route_csv(planned_route_initial_path(PROJECT))
stored = LineString([(v.x, v.y) for v in initial.vertices])
follow = LineString(project_runs["follow"]["planned"])
assert follow.hausdorff_distance(stored) < 0.01
assert abs(follow.length - stored.length) < 0.01
def test_project_branches_differ_by_goal(project_runs: dict) -> None:
"""갈래끼리 선이 수 m 이상 갈리고 · 각 갈래가 제 목표 비교값에서 가장 좋음."""
searched = {mode: project_runs[mode] for mode in SEARCHED}
for first in SEARCHED:
for second in SEARCHED:
if first < second:
gap = LineString(searched[first]["planned"]).hausdorff_distance(
LineString(searched[second]["planned"])
)
assert gap >= 3.0, (first, second, gap)
assert all(_earth(searched["earthwork"]) <= _earth(r) for r in searched.values())
top = searched["gentle"]["metrics"]["max_grade_pct"]
assert all(top <= r["metrics"]["max_grade_pct"] for r in searched.values())
for result in searched.values():
assert result["metrics"]["over_limit"]["total"] == len(result["metrics"]["violations"])