feat(B05): 추천 갈래 다시 — 영선 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소(PLAN 23-5a)

- 갈래 short · balance 뺌(요청 422) · gradeline · safe · crossings · cost 더함 — 각 목표로 제약 탐색한 후보를 묶음에 넣고 제 비교값으로 고름
- 탐색 목표 셋 더함: 목표 기울기 편차(영선 · 등경사선) · 위험(옆경사 35° 이상 · 계류 버퍼 안 통과 길이) · 계류 횡단 링크 · 공사비는 상대 단가 그대로
- 비교값 더함: side_slope_avg_pct · side_slope_max_pct · hazard_m · stream_crossings · stream_offset_avg_m · grade_dev_pct · cost_index
- 공사비 상대 단가(절성토 1 ㎥ = 1 · 연장 1 m = 2 · 계류 횡단 1 곳 = 400)는 임시 상대값 · 브레인 임의 판정
- 요청 criteria.target_grade_pct(영선 목표 기울기 · 없으면 상한) · 응답 criteria.target_grade_pct
- 시험 test_route_initial 31 · 왕복 시험은 화면이 아는 갈래만(새 넷은 23-5b 뒤)

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-30 09:12:05 +09:00
co-authored by Claude Opus 5.5
parent d9ae3a1a7a
commit a718c2d67c
8 changed files with 246 additions and 73 deletions
+82 -18
View File
@@ -25,12 +25,19 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import (
stream_lines,
write_initial_choice,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import grade_line, station_label
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
COST_CROSSING,
COST_EARTH_M3,
COST_LENGTH_M,
grade_line,
station_label,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS
from common_util.common_util_route_polyline import build_planned_polyline
SEARCHED = [mode for mode in MODES if mode != "follow"]
# 제약을 지키는 갈래(후보 묶음에서 고름) — 비중은 제약을 벌점으로만(PLAN 23-4a)
BRANCHES = ["earthwork", "gentle", "short", "balance"]
BRANCHES = ["gradeline", "earthwork", "gentle", "safe", "crossings", "cost"]
GRADE = 0.14
RADIUS = 12.0
@@ -108,19 +115,18 @@ def _earth(result: dict) -> float:
def _check_branch_goals(runs: dict) -> None:
"""토공 최소 = 절성토 · 기울기 순한 = 최대 기울기 · 짧은 노선 = 연장 · 흙 균형 = |절토 − 성토|
가 갈래(같은 후보 묶음) 가운데 가장 좋음 · 흙 남음 = 절토 − 성토."""
branches = [runs[mode] for mode in BRANCHES]
assert all(_earth(runs["earthwork"]) <= _earth(r) + 1e-6 for r in branches)
top = runs["gentle"]["metrics"]["max_grade_pct"]
assert all(top <= r["metrics"]["max_grade_pct"] + 1e-6 for r in branches)
shortest = runs["short"]["metrics"]["length_m"]
assert all(shortest <= r["metrics"]["length_m"] + 1e-6 for r in branches)
surplus = abs(runs["balance"]["metrics"]["surplus_m3"])
assert all(surplus <= abs(r["metrics"]["surplus_m3"]) + 0.1 for r in branches)
"""갈래마다 제 목표 비교값(`GOALS` — 절성토 · 최대 기울기 · 목표 기울기 편차 · 위험 길이 ·
계류 횡단 · 공사비)이 갈래(같은 후보 묶음) 가운데 1 등 · 새 비교값이 서로 맞음(PLAN 23-5)."""
branches = [runs[mode]["metrics"] for mode in BRANCHES]
for mode, goal in GOALS.items():
assert all(goal(runs[mode]["metrics"]) <= goal(m) for m in branches), mode
for result in runs.values():
metrics = result["metrics"]
assert abs(metrics["surplus_m3"] - (metrics["cut_m3"] - metrics["fill_m3"])) <= 0.11
m = result["metrics"]
assert abs(m["surplus_m3"] - (m["cut_m3"] - m["fill_m3"])) <= 0.11
assert 0.0 <= m["hazard_m"] <= m["length_m"] + 1e-6
assert m["side_slope_avg_pct"] <= m["side_slope_max_pct"] + 1e-6
cost = COST_EARTH_M3 * _earth(result) + COST_LENGTH_M * m["length_m"]
assert abs(m["cost_index"] - cost - COST_CROSSING * m["stream_crossings"]) <= 1.0
def test_each_branch_best_at_own_goal(hill: Terrain) -> None:
@@ -237,8 +243,17 @@ def test_grade_line_stays_within_limit() -> None:
def test_weights_and_stream_filter() -> None:
assert resolve_weights("weighted", None) == {"expected": 50.0, "earthwork": 25.0, "grade": 25.0}
assert resolve_weights("gentle", {"expected": 99})["grade"] == 100.0
assert set(MODES) == {"follow", "earthwork", "gentle", "weighted", "short", "balance"}
assert resolve_weights("short", {"expected": 99})["expected"] == 0.0
assert set(MODES) == {
"follow",
"gradeline",
"earthwork",
"gentle",
"safe",
"crossings",
"cost",
"weighted",
}
assert resolve_weights("cost", {"expected": 99})["expected"] == 0.0
line = {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}
features = [
{"properties": {"구분": "세류"}, "geometry": line},
@@ -293,6 +308,10 @@ def test_initial_choice_roundtrip(tmp_path) -> None:
"weights": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0},
"criteria": None,
}
for logic in ("gradeline", "safe", "crossings", "cost"): # 새 갈래도 적고 다시 읽음(23-5)
write_initial_choice(tmp_path, logic, None, {"target_grade_pct": 10.0})
assert read_initial_choice(tmp_path)["logic"] == logic
assert read_initial_choice(tmp_path)["criteria"] == {"target_grade_pct": 10.0}
write_initial_choice(tmp_path, "weighted", {"expected": 60, "earthwork": 20, "grade": 20})
assert read_initial_choice(tmp_path) == {
"logic": "weighted",
@@ -318,8 +337,13 @@ def test_request_accepts_logic_name() -> None:
assert RouteInitialRequest.model_validate({"logic": "gentle"}).mode == "gentle"
assert RouteInitialRequest.model_validate({"mode": "earthwork"}).mode == "earthwork"
assert RouteInitialRequest.model_validate({"logic": "short"}).mode == "short"
assert RouteInitialRequest.model_validate({"logic": "balance"}).mode == "balance"
for logic in ("gradeline", "safe", "crossings", "cost"):
assert RouteInitialRequest.model_validate({"logic": logic}).mode == logic
for gone in ("short", "balance"): # 23-5 에서 뺀 갈래 — 422
with pytest.raises(ValueError):
RouteInitialRequest.model_validate({"logic": gone})
criteria = RouteInitialRequest.model_validate({"criteria": {"target_grade_pct": 10}}).criteria
assert criteria is not None and criteria.target_grade_pct == 10
replan = RouteReplanRequest.model_validate({"vertices": [], "initial": {"logic": "weighted"}})
assert replan.initial is not None and replan.initial.mode == "weighted"
@@ -446,3 +470,43 @@ def test_project_weight_ends_match_branches(project_runs: dict) -> None:
def test_project_weight_moves_monotone(project_runs: dict) -> None:
_check_weight_monotone(project_runs["run"])
def test_stream_crossing_counted_once() -> None:
"""평지 · 복도를 가로지르는 소하천 한 줄 — 어느 노선이든 한 번 건넘 · 계류 횡단 최소도 한 번."""
flat = _terrain(lambda xx, yy: np.zeros_like(xx))
crossing = [[(400.0, 0.0), (400.0, 400.0)]]
for mode in ("follow", "crossings", "cost"):
metrics = _run(flat, mode, streams=crossing)["metrics"]
assert metrics["stream_crossings"] == 1, mode
assert metrics["stream_offset_avg_m"] is not None
assert _run(flat, "follow")["metrics"]["stream_crossings"] == 0
def test_gradeline_target_grade(hill: Terrain) -> None:
"""영선 노선 — 목표 기울기(없으면 상한 · 상한보다 크면 상한)."""
default = _run(hill, "gradeline")
assert default["criteria"]["target_grade_pct"] == 14.0
capped = generate_initial_route(
_line(200.0),
hill,
[],
mode="gradeline",
weights=None,
max_grade=GRADE,
min_radius_m=RADIUS,
target_grade=0.5,
)
assert capped["criteria"]["target_grade_pct"] == 14.0
low = generate_initial_route(
_line(200.0),
hill,
[],
mode="gradeline",
weights=None,
max_grade=GRADE,
min_radius_m=RADIUS,
target_grade=0.02,
)
assert low["criteria"]["target_grade_pct"] == 2.0
_kept(low)