"""사용자가 고친 계획노선을 **그대로 쓰는** 길 — 다시 풀지 않는다. 왜 필요한가(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 _vertex_z( project_root: Path, filter_key: str, method: str, smooth: bool, vertices: list[tuple[float, float]], ) -> list[float]: """노선 정점 표고 — 측점 생성과 같은 공용 표본기(1 m 쌍선형 · 네 칸 유효). 빈칸 · 격자 밖 정점은 유효한 이웃 정점 값을 누가거리로 보간(0.0 을 흘리지 않음) · 유효한 정점이 없으면 ValueError.""" from common_util.common_util_surface_sampler import build_surface_sampler sampler = build_surface_sampler(Path(project_root) / _MODELS_SUBDIR, filter_key, method, smooth) xy = np.asarray(vertices, dtype=np.float64) z, valid = sampler.sample_xy(xy) if not valid.any(): raise ValueError("계획노선 정점이 모두 지표면 밖입니다.") chain = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))]) return np.interp(chain, chain[valid], z[valid]).tolist() 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개 미만입니다.") heights = _vertex_z(project_root, filter_key, method, smooth, vertices) polyline = [[float(x), float(y), z] for (x, y), z in zip(vertices, heights)] 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), }, }