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