feat(B05): 고친 계획노선을 그대로 쓰는 입구 — 재확정 체인이 다시 풀지 않음

PLAN 0-2 항목 9 / 0-3. 노선 편집 [확인]이 재확정 체인을 타는데 그 체인이 solve_route 로
BP·CP·EP 사이를 격자에서 다시 풀었음. 사용자가 노드를 조금만 비틀어도 탐색 제약에 걸려
체인이 통째로 멈췄음(보조 창 로그: 세그먼트 1 (BP -> CP1) 경로 탐색 실패 — 종단경사
한계 26% · 최소곡선반지름 12m · 회피지역 제약).

- B05_Profile_Engine_AsPlanned.solve_as_planned 신설 — 제어점 목록이 곧 노선이고 표고만
  지표면 격자에서 뜸. 반환 꼴은 솔버와 같아 아래 단계가 그대로 이어짐.
  종단기울기·곡선반지름 위반은 **세되 막지 않음**(사용자 확정: 자동 보정·차단 없이 경고만).
- run_route_design 에 algorithm=as_planned 갈래 추가. 스키마 검증에도 허용.
- 재확정 체인이 그 갈래를 씀 — 자동탐색은 이제 초기 업로드에서만 돎.

솔버와 지표 계산이 겹치지만 합치지 않음 — 솔버는 0-3 으로 접히는 코드라 리팩터링해
두 곳을 얽을 값어치가 없음.

시험 409 통과·17 건너뜀, main import 스모크 통과.
실화면 [확인] 재검증은 보조 창이 자기 프로젝트에서 진행.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 00:49:07 +09:00
co-authored by Claude Opus 5
parent 53525a1d91
commit 674ca82726
4 changed files with 194 additions and 4 deletions
+5 -1
View File
@@ -413,7 +413,11 @@ async def run_redesign_chain(
method=str(selection.get("method") or params.get("method") or "dtm"),
smooth=bool(selection.get("smooth", params.get("smooth", False))),
surface_model_id=surface_model_id,
algorithm=str(params.get("algorithm") or "dijkstra"),
# 사용자가 고친 계획노선을 **그대로** 쓴다 — 다시 풀면 탐색 제약(종단경사·
# 최소곡선반지름·회피지역)에 걸려 체인이 통째로 멈춘다(2026-09-06 실측:
# 「세그먼트 1 (BP → CP1) 경로 탐색 실패」로 종횡단만 옛 노선에 남았음).
# PLAN 0-3 에서 자동탐색은 접기로 했고 노선은 사용자가 직접 고친다.
algorithm="as_planned",
bp=points["bp"],
ep=points["ep"],
cp=points.get("cp") or [],
+14 -1
View File
@@ -106,7 +106,20 @@ def run_route_design(
},
options=options,
)
if algorithm == "ridge_valley":
if algorithm == "as_planned":
# 사용자가 고친 계획노선을 **그대로** 쓴다 — 다시 풀지 않는다(PLAN 0-3 자동탐색 접기).
# 제어점 목록(bp·cp·ep)이 곧 노선이며, 표고만 지표면에서 뜬다.
from B05_Profile.B05_Profile_Engine_AsPlanned import solve_as_planned
sequence = [points_data.get("bp")] + list(points_data.get("cp") or [])
sequence.append(points_data.get("ep"))
vertices = [
(float(point["x"]), float(point["y"])) for point in sequence if isinstance(point, dict)
]
result = solve_as_planned(
project_root, filter_key, smooth, vertices, options, method=method
)
elif algorithm == "ridge_valley":
result = solve_ridge_valley_route(
project_root, filter_key, smooth, points_data, options, method=method
)
+169
View File
@@ -0,0 +1,169 @@
"""사용자가 고친 계획노선을 **그대로 쓰는** 길 — 다시 풀지 않는다.
왜 필요한가(2026-09-06 실측) — 노선 편집 [확인]이 재확정 체인을 타는데, 그 체인이
`solve_route` 로 **BP·CP·EP 제어점 사이를 격자에서 다시 풀었다**. 사용자가 노드를 조금만
비틀어도 탐색 제약에 걸려 체인이 통째로 멈췄다:
세그먼트 1 (BP → CP1) 경로 탐색 실패: 종단경사 한계(26%)·최소곡선반지름(12m)·
회피지역 제약으로 통과 경로가 없습니다.
그 결과 배수유역·관은 새 노선으로 가고 종횡단만 옛 노선에 남아 배수관 측점이 9 → 0 이 됐다.
PLAN 0-3 에서 **노선 자동탐색은 접기로** 했고(사용자 확정), 계획노선은 사용자가 직접 고친다.
여기서는 그 노선을 **그대로 받아** 표고만 지표면에서 떠서 결과 꼴을 맞춘다.
**위반은 세되 막지 않는다** — 종단기울기·곡선반지름을 재어 개수로 알리되 경로를 바꾸지
않는다(사용자 확정: 자동 보정·차단 없이 경고만). 막는 순간 「내가 그린 선이 안 들어간다」가 된다.
솔버(`B05_Profile_Engine_Solver`)와 지표 계산이 겹치지만 **합치지 않았다** — 솔버는 0-3 으로
접히는 코드라 그쪽을 리팩터링해 두 곳을 얽을 값어치가 없다.
"""
from __future__ import annotations
import logging
import math
from pathlib import Path
from typing import Any
import numpy as np
logger = logging.getLogger(__name__)
_MODELS_SUBDIR = "B04_PreProcess/models"
def _elevation_lookup(
project_root: Path, filter_key: str, method: str, smooth: bool
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""지표면 격자(x, y, z) — 노선 정점의 표고를 뜨는 데 쓴다."""
from B05_Profile.B05_Profile_Engine_Solver import _load_dtm_grid, _sample_surface_on_grid
models_dir = Path(project_root) / _MODELS_SUBDIR
x_coords, y_coords, dtm_z, _valid = _load_dtm_grid(models_dir, filter_key, smooth)
surface_z = _sample_surface_on_grid(
models_dir, filter_key, method, smooth, x_coords, y_coords, dtm_z
)
return x_coords, y_coords, surface_z
def _z_at(
x_coords: np.ndarray, y_coords: np.ndarray, z_grid: np.ndarray, x: float, y: float
) -> float:
"""격자에서 가장 가까운 칸의 표고. 격자 밖이면 가장자리 값."""
col = int(np.clip(np.searchsorted(x_coords, x), 0, len(x_coords) - 1))
row = int(np.clip(np.searchsorted(y_coords, y), 0, len(y_coords) - 1))
value = float(z_grid[row, col])
return value if math.isfinite(value) else 0.0
def solve_as_planned(
project_root: Path,
filter_key: str,
smooth: bool,
vertices: list[tuple[float, float]],
options: dict[str, Any],
method: str = "dtm",
) -> dict[str, Any]:
"""계획노선 정점을 그대로 노선으로 삼는다. 반환 꼴은 솔버와 같다."""
if len(vertices) < 2:
raise ValueError("계획노선 정점이 2개 미만입니다.")
x_coords, y_coords, z_grid = _elevation_lookup(project_root, filter_key, method, smooth)
polyline = [[float(x), float(y), _z_at(x_coords, y_coords, z_grid, x, y)] for x, y in vertices]
max_uphill = float(options.get("max_uphill_grade") or 0.26)
max_downhill = float(options.get("max_downhill_grade") or 0.26)
min_radius = float(options.get("min_curve_radius_m") or 12.0)
count = len(polyline)
chainage_m = [0.0] * count
length_m = 0.0
grade_sums = 0.0
max_grade = 0.0
max_up = 0.0
max_down = 0.0
slope_violations = 0
for index in range(count - 1):
x1, y1, z1 = polyline[index]
x2, y2, z2 = polyline[index + 1]
horizontal = math.hypot(x2 - x1, y2 - y1)
chainage_m[index + 1] = chainage_m[index] + horizontal
if horizontal <= 0.01:
continue
dz = z2 - z1
slope = abs(dz) / horizontal
length_m += horizontal
grade_sums += slope * horizontal
max_grade = max(max_grade, slope)
if dz > 0:
max_up = max(max_up, slope)
else:
max_down = max(max_down, slope)
if slope > (max_uphill if dz > 0 else max_downhill):
slope_violations += 1
avg_grade = (grade_sums / length_m) if length_m > 0 else 0.0
# 곡선반지름 — 세 점을 지나는 원으로 재고, 하한을 밑도는 자리를 **세기만** 한다.
from B05_Profile.B05_Profile_Engine_Geometry import circumradius_2d
curve_violations = 0
min_radius_actual = float("inf")
for index in range(1, count - 1):
radius = circumradius_2d(polyline[index - 1], polyline[index], polyline[index + 1])
min_radius_actual = min(min_radius_actual, radius)
if radius < min_radius:
curve_violations += 1
total_length = chainage_m[-1] if chainage_m else 0.0
segments = [
{
"index": 0,
"from": "BP",
"to": "EP",
"point_start": 0,
"point_end": count - 1,
"chainage_start_m": 0.0,
"chainage_end_m": round(total_length, 2),
"length_m": round(total_length, 2),
"max_grade_pct": round(max_grade * 100, 2),
}
]
logger.info(
"계획노선 그대로 사용: 정점 %d · 연장 %.1fm · 경사위반 %d · 곡선위반 %d",
count,
total_length,
slope_violations,
curve_violations,
)
return {
"polyline": polyline,
"chainage_m": [round(value, 3) for value in chainage_m],
"segments": segments,
# 사용자가 그린 선이므로 제어점 도달 검사는 뜻이 없다 — 통과로 둔다.
"required_point_checks": [],
"required_points_ok": True,
"avoid_intrusions": [],
"forbidden_intrusions": [],
"curve_warning_segments": [],
"avoid_retry_performed": False,
"conditions_snapshot": {
"filter": filter_key,
"method": method,
"smooth": smooth,
"source": "as_planned",
"min_curve_radius_m": round(min_radius, 2),
},
"metrics": {
"length_m": round(length_m, 2),
"avg_grade_pct": round(avg_grade * 100, 2),
"max_grade_pct": round(max_grade * 100, 2),
"max_uphill_pct": round(max_up * 100, 2),
"max_downhill_pct": round(max_down * 100, 2),
"slope_violations": slope_violations,
"search_max_grade_pct": round(max(max_uphill, max_downhill) * 100, 2),
"curve_violations": curve_violations,
"min_curve_radius_m": round(min_radius_actual, 2)
if math.isfinite(min_radius_actual)
else None,
"min_curve_radius_limit_m": round(min_radius, 2),
},
}
+6 -2
View File
@@ -67,7 +67,10 @@ class RouteSolveRequest(BaseModel):
method: str = Field(default="dtm", description="지표면 표현 (dtm/tin/nurbs/implicit/meshfree)")
smooth: bool = Field(default=False)
surface_model_id: int | None = Field(default=None, description="기반 지표면 모델 id")
algorithm: str = Field(default="dijkstra", description="경로 알고리즘 (dijkstra/ridge_valley)")
algorithm: str = Field(
default="dijkstra",
description="경로 알고리즘 (dijkstra/ridge_valley/as_planned)",
)
bp: RoutePoint
ep: RoutePoint
@@ -116,7 +119,8 @@ class RouteSolveRequest(BaseModel):
def validate_choices(self) -> "RouteSolveRequest":
if self.grade_class not in ROUTE_GRADE_CLASSES:
raise ValueError(f"임도 등급은 {ROUTE_GRADE_CLASSES} 중 하나여야 합니다.")
if self.algorithm not in ("dijkstra", "ridge_valley"):
# `as_planned` = 사용자가 고친 계획노선을 그대로 씀(PLAN 0-3 자동탐색 접기).
if self.algorithm not in ("dijkstra", "ridge_valley", "as_planned"):
raise ValueError("경로 알고리즘은 dijkstra 또는 ridge_valley여야 합니다.")
if self.design_speed_kph is not None and self.design_speed_kph not in (20, 30, 40):
raise ValueError("설계속도는 20/30/40 중 하나여야 합니다.")