feat(B05): 비중 = 탐색 비용의 가중합 · 짧은 노선 · 흙 균형 갈래 · 같은 노선 알림(PLAN 23-4)

- 비중 계산을 후보 고르기에서 탐색 한 번으로 — 몫(이격 · 토공 · 기울기)을 끝 노선 셋(추적 · 토공 최소 · 기울기 순한) 사이 범위로 크기 맞춤 · 제약은 (1 − 예상 몫)만큼 벌점(막지 않음) · 넘은 곳은 「비중 계산」 사유로 목록
- 끝값 100/0/0 · 0/100/0 · 0/0/100 = 추적 · 토공 최소 · 기울기 순한 그 선 · 예상 99 → 추적에서 5.0 m
- 새 갈래 short(연장 최소) · balance(|절토 − 성토| 최소) · 비교값 surplus_m3(흙 남음)
- 응답 similar(10 m 안인 다른 갈래 · 가장 먼 거리) · band_width_m(제약 안 띠 폭 어림)
- 후보 묶음을 새 모듈 _Pool 로 · 토공 ↔ 기울기 쓸기 11 벌을 묶음에 넣어 갈래가 사이 비중보다 제 목표에서 좋게
- 같은 상한 탐색은 간선 틀을 다시 씀 · 같은 노드는 다시 안 잼(첫 요청 엔진 7.1 → 2.2 초)
- 시험 test_route_initial 27

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 08:00:42 +09:00
co-authored by Claude Opus 5.5
parent bf21676ef5
commit c14717d0c2
6 changed files with 477 additions and 155 deletions
+63 -26
View File
@@ -29,6 +29,8 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import grade_line, stat
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"]
GRADE = 0.14
RADIUS = 12.0
@@ -76,7 +78,7 @@ def hill() -> Terrain:
)
@pytest.mark.parametrize("mode", SEARCHED)
@pytest.mark.parametrize("mode", BRANCHES)
def test_every_mode_keeps_grade_limit_on_hill(hill: Terrain, mode: str) -> None:
result = _run(hill, mode)
_kept(result)
@@ -105,12 +107,24 @@ 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())
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 runs.values())
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)
for result in runs.values():
metrics = result["metrics"]
assert abs(metrics["surplus_m3"] - (metrics["cut_m3"] - metrics["fill_m3"])) <= 0.11
def test_each_branch_best_at_own_goal(hill: Terrain) -> None:
_check_branch_goals({mode: _run(hill, mode) for mode in MODES})
# 비중 끝값 · 단조(PLAN 23-3e) — 예상 · 토공 · 기울기 한쪽 100 이면 그 갈래와 같은 선
@@ -144,7 +158,8 @@ def _check_weight_ends(run) -> None:
def _check_weight_monotone(run) -> None:
"""비중을 한쪽으로 옮기면 그 목표 값은 나빠지지 않음(후보가 같으니 가중합 최소의 성질)."""
"""예상 몫을 줄이면 예상노선 이격이 늘기만 함(비중 탐색 = 가중합 최소의 성질) · 토공 ↔ 기울기
끝값(갈래)은 그 사이 어느 비중보다 제 목표에서 좋음(쓸기가 후보 묶음에 들어 있음)."""
def metrics(steps):
keys = ("expected", "earthwork", "grade")
@@ -155,8 +170,8 @@ def _check_weight_monotone(run) -> None:
earth = metrics(TOWARD_EARTH)
cuts = [m["cut_m3"] + m["fill_m3"] for m in earth]
tops = [m["max_grade_pct"] for m in earth]
assert cuts == sorted(cuts, reverse=True), cuts
assert tops == sorted(tops), tops
assert cuts[-1] <= min(cuts) + 1e-6, cuts # 끝 0/100/0 = 토공 최소
assert tops[0] <= min(tops) + 1e-6, tops # 끝 0/0/100 = 기울기 순한
def test_weight_ends_match_branches(hill: Terrain) -> None:
@@ -185,7 +200,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 SEARCHED:
for mode in BRANCHES:
result = _run(flat, mode, streams=stream)
_kept(result)
assert result["metrics"]["over_limit"]["stream"] == 0
@@ -203,7 +218,7 @@ def test_stream_crossing_is_allowed() -> None:
def test_terrain_impossible_is_listed() -> None:
"""복도 전체를 가로지르는 20 m 벽 — 어떤 길도 상한을 넘음 → 목록 · 사유 · 막은 기울기."""
wall = _terrain(lambda xx, yy: np.where(xx > 400.0, 20.0, 0.0))
result = _run(wall, "weighted")
result = _run(wall, "earthwork")
_kept(result)
assert result["relaxed"] is True
assert result["terrain_bound_pct"] > GRADE * 100.0
@@ -222,7 +237,8 @@ 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"}
assert set(MODES) == {"follow", "earthwork", "gentle", "weighted", "short", "balance"}
assert resolve_weights("short", {"expected": 99})["expected"] == 0.0
line = {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}
features = [
{"properties": {"구분": "세류"}, "geometry": line},
@@ -302,6 +318,8 @@ 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"
replan = RouteReplanRequest.model_validate({"vertices": [], "initial": {"logic": "weighted"}})
assert replan.initial is not None and replan.initial.mode == "weighted"
@@ -385,20 +403,39 @@ def test_project_follow_is_preprocess_initial(project_runs: dict) -> None:
def test_project_branches_differ_by_goal(project_runs: dict) -> None:
"""갈래끼리 선이 수 m 이상 갈리고 · 각 갈래가 제 목표 비교값에서 가장 좋음."""
searched = {mode: project_runs[mode] for mode in SEARCHED} # 비중은 50/25/25
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"])
"""각 갈래가 제 목표 비교값에서 가장 좋음 · 토공 최소와 기울기 순한은 선이 갈림(6.1 m)."""
_check_branch_goals({mode: project_runs[mode] for mode in MODES})
assert _gap(project_runs["earthwork"], project_runs["gentle"]) >= 3.0
for mode in MODES:
metrics = project_runs[mode]["metrics"]
assert metrics["over_limit"]["total"] == len(metrics["violations"])
def test_project_similar_and_band(project_runs: dict) -> None:
"""가파른 지형 — 제약 안 길이 한 띠(띠 폭 < 20 m) · 10 m 안인 다른 갈래를 `similar` 로 알림."""
earthwork = project_runs["earthwork"]
assert earthwork["band_width_m"] < 20.0
near = {item["logic"]: item["max_gap_m"] for item in earthwork["similar"]}
assert near["gentle"] == round(_gap(earthwork, project_runs["gentle"]), 1)
assert all(gap < 10.0 for gap in near.values()) and "earthwork" not in near
assert "follow" not in near # 추적은 40 m 넘게 떨어짐
def test_project_expected_axis_moves_little_by_little(project_runs: dict) -> None:
"""예상 100 → 0(토공 : 기울기 1 : 1) — 99 는 추적에서 몇 m 안 · 이웃 단계마다 노선이 움직임 ·
추적에서 점점 멀어짐(이격 늘기만) · 비중 탐색은 넘은 곳을 「비중 계산」 사유로 알림."""
run = project_runs["run"]
near = run("weighted", {"expected": 99, "earthwork": 0.5, "grade": 0.5})
assert 0.0 < _gap(near, project_runs["follow"]) < 10.0
steps = [
run("weighted", {"expected": e, "earthwork": (100 - e) / 2, "grade": (100 - e) / 2})
for e in range(100, -1, -10)
]
assert all(_gap(a, b) > 0.0 for a, b in zip(steps, steps[1:]))
offsets = [step["metrics"]["offset_avg_m"] for step in steps]
assert offsets == sorted(offsets), offsets
reasons = [v["reason"] for v in steps[5]["metrics"]["violations"] if v["kind"] == "grade"]
assert reasons and all(reason.startswith("비중 계산") for reason in reasons)
def test_project_weight_ends_match_branches(project_runs: dict) -> None: