"""사용자가 고친 계획노선을 **그대로 쓰는** 길 — 다시 풀지 않는다. 왜 필요한가(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), }, }