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:
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user