From 674ca8272618a3e55cf98ee3d4dbb369fbd36d05 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 00:49:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EA=B3=A0=EC=B9=9C=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EB=85=B8=EC=84=A0=EC=9D=84=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=EC=93=B0=EB=8A=94=20=EC=9E=85=EA=B5=AC=20=E2=80=94?= =?UTF-8?q?=20=EC=9E=AC=ED=99=95=EC=A0=95=20=EC=B2=B4=EC=9D=B8=EC=9D=B4=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=ED=92=80=EC=A7=80=20=EC=95=8A=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- B03_FileInput/B03_FileInput_Service_Chain.py | 6 +- B05_Profile/B05_Profile_Engine.py | 15 +- B05_Profile/B05_Profile_Engine_AsPlanned.py | 169 +++++++++++++++++++ B05_Profile/B05_Profile_Schema.py | 8 +- 4 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 B05_Profile/B05_Profile_Engine_AsPlanned.py diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index fb91884f..b180c33d 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -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 [], diff --git a/B05_Profile/B05_Profile_Engine.py b/B05_Profile/B05_Profile_Engine.py index eae556fd..46f360d9 100644 --- a/B05_Profile/B05_Profile_Engine.py +++ b/B05_Profile/B05_Profile_Engine.py @@ -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 ) diff --git a/B05_Profile/B05_Profile_Engine_AsPlanned.py b/B05_Profile/B05_Profile_Engine_AsPlanned.py new file mode 100644 index 00000000..d176d08c --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_AsPlanned.py @@ -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), + }, + } diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index 0ccfff61..d2790ec5 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -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 중 하나여야 합니다.")