- 노선 꼭짓점 z: 한 칸 값 · 빈칸 0.0 대신 build_surface_sampler · 빈칸 · 밖은 이웃 정점 누가거리 보간 · 모두 밖이면 멈춤 - 종단 미리보기 · [확인] 균형 목표: 2 m 비용면 대신 1 m 표본기(추천 점수 비용면은 그대로) - 시험 test_342_4_ground_unify.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
168 lines
6.6 KiB
Python
168 lines
6.6 KiB
Python
"""사용자가 고친 계획노선을 **그대로 쓰는** 길 — 다시 풀지 않는다.
|
|
|
|
왜 필요한가(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),
|
|
},
|
|
}
|