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
+21 -8
View File
@@ -1,6 +1,7 @@
"""초기 계획노선 갈래(PLAN 20 · 23장).
예상노선 추종 · 토공 최소 · 기울기 순한 · 비중 · 짧은 노선 · 흙 균형.
예상노선 추종 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 ·
공사비 어림 최소 · 비중.
계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이
받은 노드로 기존 `POST /route/replan`([확인])을 부른다.
@@ -10,8 +11,12 @@
제약 탐색은 하지 않고, 넘은 곳은 `metrics.violations` 에 뜬다.
· earthwork — 후보 중 절 · 성토 합(`cut_m3 + fill_m3`) 최소.
· gentle — 후보 중 최대 종단기울기 최소(같으면 평균 최소).
· short — 후보 중 연장 최소(PLAN 23-4c).
· balance — 후보 중 흙 남음 |절토 − 성토| 최소 · 같으면 절성토 합 최소(PLAN 23-4c).
· gradeline — 영선 노선: 목표 기울기(`target_grade` · 없으면 상한)로 등고선을 건너가는
등경사선(PEGGER 식) · 후보 중 목표 기울기 편차(`grade_dev_pct`) 최소(PLAN 23-5).
· safe — 안전 우선: 후보 중 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) 최소.
· crossings — 계류 횡단 최소: 후보 중 계류 횡단 수 최소 · 같으면 계류에서 먼 쪽.
· cost — 공사비 어림 최소: 후보 중 상대 공사비(토공 + 계류 횡단 구조물 + 연장 · 임시 상대
단가 `..._Metrics.COST_*`) 최소.
· weighted — 탐색 비용의 가중합 한 번(후보 고르기 아님 · PLAN 23-4a · `..._Pool.weighted`).
끝값은 그 갈래와 같은 선 — 예상 100 = 추적 · 토공 100 = 토공 최소 · 기울기 100 =
기울기 순한. 제약은 (1 − 예상 몫)만큼 벌점으로만 따지고 넘은 곳은 목록에 뜸.
@@ -83,15 +88,17 @@ STREAM_CLASSES = ("소하천", "지방하천", "국가하천")
PENDING: tuple[str, ...] = ()
# ── 탐색 · 측정 값 ───────────────────────────────────────────────────────────────
MODES = ("follow", "earthwork", "gentle", "weighted", "short", "balance")
MODES = ("follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted")
DEFAULT_WEIGHTS = {"expected": 50.0, "earthwork": 25.0, "grade": 25.0}
MODE_WEIGHTS = {
"follow": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0},
"earthwork": {"expected": 0.0, "earthwork": 100.0, "grade": 0.0},
"gentle": {"expected": 0.0, "earthwork": 0.0, "grade": 100.0},
# 비중 몫이 없는 갈래 — 연장 · 흙 균형은 비교값으로만 고름
"short": {"expected": 0.0, "earthwork": 0.0, "grade": 0.0},
"balance": {"expected": 0.0, "earthwork": 0.0, "grade": 0.0},
# 비중 몫이 없는 갈래(PLAN 23-5) — 제 목표로 찾은 후보에서 비교값으로 고름
**{
mode: {"expected": 0.0, "earthwork": 0.0, "grade": 0.0}
for mode in ("gradeline", "safe", "crossings", "cost")
},
}
# 후보 묶음은 갈래와 무관 — 같은 입력이면 다시 짜지 않음(최근 몇 벌만 · 프로세스 메모리)
POOL_CACHE_SIZE = 4
@@ -208,6 +215,7 @@ def generate_initial_route(
min_radius_m: float,
stream_offset_m: float | None = None,
grade_smooth_m: float | None = None,
target_grade: float | None = None,
) -> dict[str, Any]:
"""갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14).
@@ -222,6 +230,8 @@ def generate_initial_route(
used = resolve_weights(mode, weights)
stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m)
grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m)
# 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만)
goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade)
def measure(
outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False
@@ -240,6 +250,7 @@ def generate_initial_route(
grade_smooth_m=grade_smooth,
follow=follow,
weighted=weighted,
target_grade=goal_grade,
)
def follow_line() -> tuple[Any, dict[str, Any]]:
@@ -252,7 +263,7 @@ def generate_initial_route(
pool: Pool | None = None
target = _weighted_end(used) if mode == "weighted" else mode
key = _pool_key(
expected, terrain, streams, max_grade, min_radius_m, stream_offset, grade_smooth
expected, terrain, streams, max_grade, min_radius_m, stream_offset, grade_smooth, goal_grade
)
if target == "follow": # 비중 예상 100 = 추적 그 선(PLAN 23-3e) · 묶음은 짜지 않음(빠른 길)
outline, metrics = follow_line()
@@ -265,6 +276,7 @@ def generate_initial_route(
streams,
stream_offset_m=stream_offset,
grade_smooth_m=grade_smooth,
target_grade=goal_grade,
)
graph = RouteGraph(grid, expected, limit=max_grade, min_radius_m=min_radius_m)
while len(_POOLS) >= POOL_CACHE_SIZE:
@@ -297,6 +309,7 @@ def generate_initial_route(
"road_width_m": ROAD_WIDTH_M,
"corridor_m": CORRIDOR_M,
"grade_smooth_m": round(grade_smooth, 2),
"target_grade_pct": round(goal_grade * 100.0, 2),
# 기준값 확정(PLAN 20장, 2026-09-29) — 더는 후보가 아니다. 항상 빈 목록.
"pending": list(PENDING),
},