260705_2
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
"""B05 경로 설계 엔진 오케스트레이터.
|
||||
|
||||
경로점(BP/CP/EP/AP/FP)과 옵션을 받아 최적 경로를 계산하고, 폴리라인을
|
||||
GeoJSON으로 저장하며 DB 기록용 데이터(메타·렌더링 샘플·통계)를 준비한다.
|
||||
라우터에서 asyncio.to_thread로 호출한다.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_RidgeValley import solve_ridge_valley_route
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Solver import solve_optimal_route
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
_ROUTE_SUBDIR = Path("B05_wf2_Route") / "route"
|
||||
# route_points 테이블에 저장할 렌더링 샘플 최대 개수
|
||||
_MAX_RENDER_POINTS = 500
|
||||
|
||||
|
||||
def _route_geojson(polyline: list[list[float]]) -> dict[str, Any]:
|
||||
"""폴리라인을 3D LineString GeoJSON Feature로 변환한다."""
|
||||
return {
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [[round(x, 3), round(y, 3), round(z, 3)] for x, y, z in polyline],
|
||||
},
|
||||
"properties": {},
|
||||
}
|
||||
|
||||
|
||||
def _sample_render_points(
|
||||
polyline: list[list[float]], chainage_m: list[float], maximum: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""폴리라인을 최대 maximum개로 균등 샘플링해 렌더링용 포인트를 만든다."""
|
||||
n = len(polyline)
|
||||
if n == 0:
|
||||
return []
|
||||
if n <= maximum:
|
||||
indices = range(n)
|
||||
else:
|
||||
step = n / maximum
|
||||
indices = (int(i * step) for i in range(maximum))
|
||||
|
||||
points: list[dict[str, Any]] = []
|
||||
for seq, idx in enumerate(indices):
|
||||
idx = min(idx, n - 1)
|
||||
_, _, z = polyline[idx]
|
||||
# 국소 경사(%) — 직전 정점과의 차이
|
||||
slope_pct = 0.0
|
||||
if idx > 0:
|
||||
x0, y0, z0 = polyline[idx - 1]
|
||||
x1, y1, z1 = polyline[idx]
|
||||
h = ((x1 - x0) ** 2 + (y1 - y0) ** 2) ** 0.5
|
||||
if h > 1e-6:
|
||||
slope_pct = abs(z1 - z0) / h * 100.0
|
||||
points.append(
|
||||
{
|
||||
"chainage_m": round(chainage_m[idx], 3) if idx < len(chainage_m) else None,
|
||||
"elevation_m": round(z, 3),
|
||||
"slope_percent": round(slope_pct, 3),
|
||||
"sequence_num": seq,
|
||||
}
|
||||
)
|
||||
return points
|
||||
|
||||
|
||||
def run_route_design(
|
||||
project_root: Path,
|
||||
filter_key: str,
|
||||
method: str,
|
||||
smooth: bool,
|
||||
points_data: dict[str, Any],
|
||||
options: dict[str, Any],
|
||||
algorithm: str = "dijkstra",
|
||||
) -> dict[str, Any]:
|
||||
"""경로 탐색을 실행하고 GeoJSON 저장 + DB 기록용 데이터를 반환한다.
|
||||
|
||||
algorithm: "dijkstra"(격자 Dijkstra) 또는 "ridge_valley"(능선-계곡 정속경사).
|
||||
|
||||
반환 dict:
|
||||
- route_data_path: 저장한 GeoJSON의 프로젝트 상대 경로
|
||||
- solver_result: solver 원본 결과 (polyline, metrics, segments 등)
|
||||
- render_points: route_points 테이블 저장용 샘플
|
||||
- statistics: route_statistics 저장용 요약
|
||||
"""
|
||||
if algorithm == "ridge_valley":
|
||||
result = solve_ridge_valley_route(
|
||||
project_root, filter_key, smooth, points_data, options, method=method
|
||||
)
|
||||
else:
|
||||
result = solve_optimal_route(
|
||||
project_root, filter_key, smooth, points_data, options, method=method
|
||||
)
|
||||
polyline = result["polyline"]
|
||||
chainage_m = result["chainage_m"]
|
||||
|
||||
route_dir = project_root / _ROUTE_SUBDIR
|
||||
route_dir.mkdir(parents=True, exist_ok=True)
|
||||
geojson_path = route_dir / "route_main.geojson"
|
||||
atomic_write_json(geojson_path, _route_geojson(polyline))
|
||||
|
||||
# 통계 요약 (solver 메트릭에서 파생)
|
||||
metrics = result["metrics"]
|
||||
statistics = {
|
||||
"min_slope": 0.0,
|
||||
"max_slope": metrics.get("max_grade_pct"),
|
||||
"mean_slope": metrics.get("avg_grade_pct"),
|
||||
"cost_score": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"route_data_path": geojson_path.relative_to(project_root).as_posix(),
|
||||
"solver_result": result,
|
||||
"render_points": _sample_render_points(polyline, chainage_m, _MAX_RENDER_POINTS),
|
||||
"statistics": statistics,
|
||||
"grade_percent": [seg.get("max_grade_pct") for seg in result.get("segments", [])],
|
||||
"constraints": result.get("conditions_snapshot", {}),
|
||||
"algorithm_params": options.get("weights") or {},
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
"""B05 경로 설계 기하 유틸리티 및 단일 구간 Dijkstra.
|
||||
|
||||
8-연결 격자에서 종단경사·측사면·곡률을 비용으로 반영하는 방향 인식
|
||||
상태공간 Dijkstra와, 곡률/거리/교차 계산 등 순수 기하 헬퍼를 제공한다.
|
||||
config에 독립적이며 모든 파라미터는 호출측에서 주입한다.
|
||||
"""
|
||||
|
||||
import heapq
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def get_neighbors(r: int, c: int, rows: int, cols: int):
|
||||
"""8-연결 이웃을 (nr, nc, dir_idx)로 순회한다. (방향 인덱스 0..7)"""
|
||||
neighbors = [
|
||||
(-1, 0, 4), # S
|
||||
(-1, 1, 3), # SE
|
||||
(0, 1, 2), # E
|
||||
(1, 1, 1), # NE
|
||||
(1, 0, 0), # N
|
||||
(1, -1, 7), # NW
|
||||
(0, -1, 6), # W
|
||||
(-1, -1, 5), # SW
|
||||
]
|
||||
for dr, dc, d_idx in neighbors:
|
||||
nr, nc = r + dr, c + dc
|
||||
if 0 <= nr < rows and 0 <= nc < cols:
|
||||
yield nr, nc, d_idx
|
||||
|
||||
|
||||
def compute_gradients(
|
||||
z_grid: np.ndarray, res_y: float, res_x: float | None = None
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""실제 격자 간격으로 지형 기울기를 계산해 (dz_dx, dz_dy)를 반환한다."""
|
||||
if res_x is None:
|
||||
res_x = res_y
|
||||
dz_dy, dz_dx = np.gradient(z_grid, res_y, res_x)
|
||||
return dz_dx, dz_dy
|
||||
|
||||
|
||||
def side_slope_magnitude(dr: int, dc: int, gx: float, gy: float) -> float:
|
||||
"""진행 방향에 수직인 지형 기울기(측사면=절토/성토 프록시)의 크기."""
|
||||
norm = math.hypot(dc, dr)
|
||||
if norm == 0:
|
||||
return 0.0
|
||||
px, py = -dr / norm, dc / norm
|
||||
return abs(gx * px + gy * py)
|
||||
|
||||
|
||||
def circumradius_2d(p0, p1, p2) -> float:
|
||||
"""연속 세 점을 지나는 수평면 외접원 반지름(중간 정점의 이산 곡률 반경)."""
|
||||
ax, ay = p0[0], p0[1]
|
||||
bx, by = p1[0], p1[1]
|
||||
cx, cy = p2[0], p2[1]
|
||||
a = math.hypot(bx - cx, by - cy)
|
||||
b = math.hypot(ax - cx, ay - cy)
|
||||
c = math.hypot(ax - bx, ay - by)
|
||||
if a < 0.01 or b < 0.01 or c < 0.01:
|
||||
return float("inf")
|
||||
area2 = abs((bx - ax) * (cy - ay) - (cx - ax) * (by - ay))
|
||||
if area2 < 1e-9:
|
||||
return float("inf")
|
||||
return (a * b * c) / (2.0 * area2)
|
||||
|
||||
|
||||
def point_to_polyline_dist_2d(px: float, py: float, polyline) -> float:
|
||||
"""점에서 폴리라인까지 최소 수평거리(점-선분 거리)."""
|
||||
if not polyline:
|
||||
return float("inf")
|
||||
best = float("inf")
|
||||
for i in range(len(polyline) - 1):
|
||||
ax, ay = polyline[i][0], polyline[i][1]
|
||||
bx, by = polyline[i + 1][0], polyline[i + 1][1]
|
||||
dx, dy = bx - ax, by - ay
|
||||
seg2 = dx * dx + dy * dy
|
||||
if seg2 <= 1e-12:
|
||||
d = math.hypot(px - ax, py - ay)
|
||||
else:
|
||||
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / seg2))
|
||||
d = math.hypot(px - (ax + t * dx), py - (ay + t * dy))
|
||||
if d < best:
|
||||
best = d
|
||||
if len(polyline) == 1:
|
||||
best = math.hypot(px - polyline[0][0], py - polyline[0][1])
|
||||
return best
|
||||
|
||||
|
||||
def circle_intrusions(polyline, circles, labeler) -> list[dict[str, Any]]:
|
||||
"""각 원(AP/FP)에 대해 경로의 최소 이격거리와 원 내부 폴리라인 길이를 보고한다."""
|
||||
out = []
|
||||
for i, circ in enumerate(circles):
|
||||
cx, cy, radius = circ["x"], circ["y"], circ["radius_m"]
|
||||
clearance = point_to_polyline_dist_2d(cx, cy, polyline)
|
||||
intruded_len = 0.0
|
||||
for j in range(len(polyline) - 1):
|
||||
ax, ay = polyline[j][0], polyline[j][1]
|
||||
bx, by = polyline[j + 1][0], polyline[j + 1][1]
|
||||
mx, my = 0.5 * (ax + bx), 0.5 * (ay + by)
|
||||
if math.hypot(mx - cx, my - cy) < radius:
|
||||
intruded_len += math.hypot(bx - ax, by - ay)
|
||||
out.append(
|
||||
{
|
||||
"index": i,
|
||||
"label": labeler(i),
|
||||
"x": cx,
|
||||
"y": cy,
|
||||
"radius_m": radius,
|
||||
"min_clearance_m": round(clearance, 3),
|
||||
"intrusion_length_m": round(intruded_len, 3),
|
||||
"intrudes": bool(clearance < radius or intruded_len > 0.0),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def resample_polyline_2d(polyline, chainage_m, step_m: float):
|
||||
"""폴리라인을 고정 호장 간격으로 재샘플한다(도로 스케일 곡률 측정용)."""
|
||||
if len(polyline) < 2 or step_m <= 0:
|
||||
return [[p[0], p[1]] for p in polyline]
|
||||
total = chainage_m[-1]
|
||||
if total <= 0:
|
||||
return [[polyline[0][0], polyline[0][1]]]
|
||||
targets = np.arange(0.0, total + step_m * 0.5, step_m)
|
||||
out = []
|
||||
j = 0
|
||||
for t in targets:
|
||||
while j < len(chainage_m) - 2 and chainage_m[j + 1] < t:
|
||||
j += 1
|
||||
seg_len = chainage_m[j + 1] - chainage_m[j]
|
||||
frac = 0.0 if seg_len <= 1e-9 else (t - chainage_m[j]) / seg_len
|
||||
frac = min(max(frac, 0.0), 1.0)
|
||||
x = polyline[j][0] + frac * (polyline[j + 1][0] - polyline[j][0])
|
||||
y = polyline[j][1] + frac * (polyline[j + 1][1] - polyline[j][1])
|
||||
out.append([x, y])
|
||||
return out
|
||||
|
||||
|
||||
def turn_radius_from_grid(diff: int, grid_res: float) -> float:
|
||||
"""8-연결 격자의 단일 방향전환이 함의하는 곡선 반경(m) 근사."""
|
||||
if diff <= 0:
|
||||
return float("inf")
|
||||
angle_rad = math.radians(45.0 * diff)
|
||||
step_len = grid_res * (math.sqrt(2) if diff == 1 else 1.0)
|
||||
return step_len / angle_rad
|
||||
|
||||
|
||||
def single_segment_dijkstra(
|
||||
r_start: int,
|
||||
c_start: int,
|
||||
r_end: int,
|
||||
c_end: int,
|
||||
x_coords: np.ndarray,
|
||||
y_coords: np.ndarray,
|
||||
z_grid: np.ndarray,
|
||||
valid_mask: np.ndarray,
|
||||
dz_dx: np.ndarray,
|
||||
dz_dy: np.ndarray,
|
||||
ap_list: list[dict[str, Any]],
|
||||
weights: dict[str, float],
|
||||
max_grade: float,
|
||||
grid_res: float,
|
||||
min_curve_radius_m: float,
|
||||
max_uphill_grade: float | None = None,
|
||||
max_downhill_grade: float | None = None,
|
||||
) -> list[tuple[int, int]]:
|
||||
"""시작→끝 셀 방향 인식 상태공간 Dijkstra 탐색.
|
||||
|
||||
하드 제약: 종단경사 초과 링크·135도 이상 급전환은 통행불가.
|
||||
min_curve_radius_m은 방향전환 소프트 패널티로 반영된다.
|
||||
"""
|
||||
rows, cols = z_grid.shape
|
||||
w_dist = weights.get("dist", 1.0)
|
||||
w_grade = weights.get("grade", 2.0)
|
||||
w_side = weights.get("side", 1.5)
|
||||
w_curve = weights.get("curve", 0.5)
|
||||
w_avoid = weights.get("avoid", 10.0)
|
||||
|
||||
up_limit = max_uphill_grade if max_uphill_grade else max_grade
|
||||
down_limit = max_downhill_grade if max_downhill_grade else max_grade
|
||||
|
||||
dist: dict[tuple[int, int, int], float] = {}
|
||||
parent: dict[tuple[int, int, int], tuple[int, int, int]] = {}
|
||||
pq: list[tuple[float, int, int, int]] = []
|
||||
|
||||
for d in range(8):
|
||||
dist[(r_start, c_start, d)] = 0.0
|
||||
heapq.heappush(pq, (0.0, r_start, c_start, d))
|
||||
|
||||
found_dest = False
|
||||
best_dest_state = None
|
||||
|
||||
while pq:
|
||||
d_cost, r, c, d = heapq.heappop(pq)
|
||||
if d_cost > dist.get((r, c, d), float("inf")):
|
||||
continue
|
||||
if r == r_end and c == c_end:
|
||||
found_dest = True
|
||||
best_dest_state = (r, c, d)
|
||||
break
|
||||
|
||||
for nr, nc, nd in get_neighbors(r, c, rows, cols):
|
||||
if not valid_mask[nr, nc]:
|
||||
continue
|
||||
|
||||
diff = abs(d - nd)
|
||||
diff = min(diff, 8 - diff)
|
||||
if diff >= 3:
|
||||
continue # U턴/급전환은 통행불가
|
||||
|
||||
curve_penalty = 0.0
|
||||
if diff > 0:
|
||||
turn_radius = turn_radius_from_grid(diff, grid_res)
|
||||
tightness = min(min_curve_radius_m / turn_radius, 20.0)
|
||||
curve_penalty = w_curve * tightness * grid_res
|
||||
|
||||
is_diagonal = nd % 2 != 0
|
||||
h_dist = grid_res * math.sqrt(2) if is_diagonal else grid_res
|
||||
|
||||
z_curr = z_grid[r, c]
|
||||
z_next = z_grid[nr, nc]
|
||||
dz = z_next - z_curr
|
||||
grade = abs(dz) / h_dist
|
||||
|
||||
applicable_limit = up_limit if dz > 0 else down_limit
|
||||
if grade > applicable_limit:
|
||||
continue
|
||||
f_grade = (grade / applicable_limit) ** 2 * 10.0
|
||||
|
||||
side_slope = side_slope_magnitude(nr - r, nc - c, dz_dx[nr, nc], dz_dy[nr, nc])
|
||||
f_side = side_slope * 5.0
|
||||
|
||||
nx_model = x_coords[nc]
|
||||
ny_model = y_coords[nr]
|
||||
avoid_penalty = 0.0
|
||||
for ap in ap_list:
|
||||
dist_to_ap = math.hypot(nx_model - ap["x"], ny_model - ap["y"])
|
||||
if dist_to_ap < ap["radius_m"]:
|
||||
avoid_penalty += w_avoid * 1000.0 * h_dist
|
||||
|
||||
link_cost = (
|
||||
w_dist * h_dist
|
||||
+ w_grade * f_grade * h_dist
|
||||
+ w_side * f_side * h_dist
|
||||
+ curve_penalty
|
||||
+ avoid_penalty
|
||||
)
|
||||
next_cost = d_cost + link_cost
|
||||
state_next = (nr, nc, nd)
|
||||
if next_cost < dist.get(state_next, float("inf")):
|
||||
dist[state_next] = next_cost
|
||||
parent[state_next] = (r, c, d)
|
||||
heapq.heappush(pq, (next_cost, nr, nc, nd))
|
||||
|
||||
if not found_dest:
|
||||
return []
|
||||
|
||||
path = []
|
||||
curr = best_dest_state
|
||||
while curr:
|
||||
path.append((curr[0], curr[1]))
|
||||
curr = parent.get(curr)
|
||||
return path[::-1]
|
||||
@@ -0,0 +1,783 @@
|
||||
"""B05 능선-계곡 정속경사 임도 길찾기 (대안 알고리즘).
|
||||
|
||||
격자 Dijkstra와 분리된 방식: 지형 스켈레톤에서 주/지 능선·계곡 polyline을
|
||||
얻고, 능선↔계곡 노드 쌍을 잇는 정속경사 직선 세그먼트로 그래프를 만든 뒤
|
||||
교각 페널티 Dijkstra로 노드 시퀀스를 탐색한다. 최종 선형은 직선+최소회전반경
|
||||
원호(fillet)로 구성한다. 반환 스키마는 solve_optimal_route()와 동일하다.
|
||||
"""
|
||||
|
||||
import heapq
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Geometry import (
|
||||
circle_intrusions,
|
||||
circumradius_2d,
|
||||
point_to_polyline_dist_2d,
|
||||
resample_polyline_2d,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Skeleton import load_or_build_skeleton
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Solver import (
|
||||
_MODELS_SUBDIR,
|
||||
_load_or_build_cost_surface,
|
||||
)
|
||||
from config.config_system import (
|
||||
FOREST_ROAD_MIN_CURVE_R_M,
|
||||
ROUTE_ALT_GRADE_TOLERANCE,
|
||||
ROUTE_ALT_MAX_GRADE,
|
||||
ROUTE_ALT_MIN_GRADE,
|
||||
ROUTE_DEFAULT_GRADE_CLASS,
|
||||
ROUTE_REQUIRED_POINT_TOLERANCE_M,
|
||||
SKELETON_NODE_SPACING_M,
|
||||
)
|
||||
|
||||
# 엣지 후보 탐색 파라미터 (알고리즘 내부 상수)
|
||||
MAX_EDGE_LEN_M = 400.0
|
||||
MIN_EDGE_LEN_M = 20.0
|
||||
MAX_NEIGHBORS_PER_NODE = 16
|
||||
TURN_PENALTY_W = 60.0
|
||||
MAX_TURN_DEG = 120.0
|
||||
|
||||
|
||||
class _Grid:
|
||||
"""비용면 격자에 대한 표고/유효성 조회 헬퍼."""
|
||||
|
||||
def __init__(self, x, y, z, valid, grid_res):
|
||||
self.x = np.asarray(x, dtype=np.float64)
|
||||
self.y = np.asarray(y, dtype=np.float64)
|
||||
self.z = np.asarray(z, dtype=np.float64)
|
||||
self.valid = np.asarray(valid, dtype=bool)
|
||||
self.res = float(grid_res)
|
||||
|
||||
def _idx(self, coords: np.ndarray, v: float) -> int:
|
||||
i = int(np.clip(np.searchsorted(coords, v), 0, len(coords) - 1))
|
||||
j = max(i - 1, 0)
|
||||
return j if abs(v - coords[j]) <= abs(coords[i] - v) else i
|
||||
|
||||
def rc(self, px: float, py: float) -> tuple[int, int]:
|
||||
return self._idx(self.y, py), self._idx(self.x, px)
|
||||
|
||||
def z_at(self, px: float, py: float) -> float:
|
||||
r, c = self.rc(px, py)
|
||||
return float(self.z[r, c])
|
||||
|
||||
def valid_at(self, px: float, py: float) -> bool:
|
||||
in_bounds = (self.x[0] <= px <= self.x[-1]) and (self.y[0] <= py <= self.y[-1])
|
||||
if not in_bounds:
|
||||
return False
|
||||
r, c = self.rc(px, py)
|
||||
return bool(self.valid[r, c])
|
||||
|
||||
|
||||
def _collect_nodes(
|
||||
skeleton: dict[str, Any], spacing_m: float
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""능선/계곡 polyline 정점을 spacing 간격으로 다운샘플해 노드 배열을 만든다."""
|
||||
pos, kind, on_main = [], [], []
|
||||
|
||||
def _add(polys, k, is_main):
|
||||
for item in polys:
|
||||
pl = item["polyline"]
|
||||
acc = spacing_m
|
||||
prev = None
|
||||
for p in pl:
|
||||
step = spacing_m if prev is None else math.hypot(p[0] - prev[0], p[1] - prev[1])
|
||||
acc += step
|
||||
prev = p
|
||||
if acc >= spacing_m:
|
||||
acc = 0.0
|
||||
pos.append([p[0], p[1], p[2]])
|
||||
kind.append(k)
|
||||
on_main.append(is_main)
|
||||
|
||||
_add(skeleton.get("minor_ridge", []), 0, False)
|
||||
_add(skeleton.get("minor_valley", []), 1, False)
|
||||
_add(skeleton.get("main_ridge", []), 0, True)
|
||||
_add(skeleton.get("main_valley", []), 1, True)
|
||||
|
||||
if not pos:
|
||||
return (np.zeros((0, 3)), np.zeros(0, dtype=np.int8), np.zeros(0, dtype=bool))
|
||||
return (
|
||||
np.asarray(pos, dtype=np.float64),
|
||||
np.asarray(kind, dtype=np.int8),
|
||||
np.asarray(on_main, dtype=bool),
|
||||
)
|
||||
|
||||
|
||||
def _build_barrier_mask(grid: _Grid, skeleton: dict[str, Any]) -> np.ndarray:
|
||||
"""주능선/주계곡 셀을 True로 표시한 구획 경계 마스크."""
|
||||
barrier = np.zeros(grid.z.shape, dtype=bool)
|
||||
for key in ("main_ridge", "main_valley"):
|
||||
for item in skeleton.get(key, []):
|
||||
for p in item["polyline"]:
|
||||
r, c = grid.rc(p[0], p[1])
|
||||
barrier[r, c] = True
|
||||
return barrier
|
||||
|
||||
|
||||
def _segment_feasible(
|
||||
a: np.ndarray,
|
||||
b: np.ndarray,
|
||||
grid: _Grid,
|
||||
barrier: np.ndarray,
|
||||
blocked_circles: list[dict[str, float]],
|
||||
min_grade: float,
|
||||
max_grade: float,
|
||||
tol: float,
|
||||
endpoint_free_m: float,
|
||||
enforce_grade_window: bool = True,
|
||||
) -> bool:
|
||||
"""a→b 직선이 정속경사 세그먼트로 성립하는지 검사한다."""
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
length = math.hypot(dx, dy)
|
||||
if length < 1e-6:
|
||||
return False
|
||||
design_grade = (b[2] - a[2]) / length
|
||||
g = abs(design_grade)
|
||||
if enforce_grade_window:
|
||||
if not (min_grade <= g <= max_grade):
|
||||
return False
|
||||
elif g > max_grade:
|
||||
return False
|
||||
|
||||
step = max(grid.res, 1.0)
|
||||
n_steps = max(int(length / step), 1)
|
||||
for i in range(n_steps + 1):
|
||||
t = i / n_steps
|
||||
px, py = a[0] + t * dx, a[1] + t * dy
|
||||
if not grid.valid_at(px, py):
|
||||
return False
|
||||
s = t * length
|
||||
z_design = a[2] + design_grade * s
|
||||
z_terrain = grid.z_at(px, py)
|
||||
allowed = tol * max(s, length - s) + grid.res
|
||||
if abs(z_terrain - z_design) > allowed:
|
||||
return False
|
||||
if min(s, length - s) > endpoint_free_m:
|
||||
r, c = grid.rc(px, py)
|
||||
if barrier[r, c]:
|
||||
return False
|
||||
for circ in blocked_circles:
|
||||
if math.hypot(px - circ["x"], py - circ["y"]) < circ["radius_m"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _build_edges(
|
||||
pos: np.ndarray,
|
||||
kind: np.ndarray,
|
||||
grid: _Grid,
|
||||
barrier: np.ndarray,
|
||||
blocked_circles: list[dict[str, float]],
|
||||
min_grade: float,
|
||||
max_grade: float,
|
||||
tol: float,
|
||||
endpoint_free_m: float,
|
||||
) -> dict[int, list[tuple[int, float]]]:
|
||||
"""능선↔계곡 노드 쌍의 정속경사 직선 엣지를 만든다 (무방향, 길이 저장)."""
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
adj: dict[int, list[tuple[int, float]]] = {i: [] for i in range(len(pos))}
|
||||
if len(pos) == 0:
|
||||
return adj
|
||||
|
||||
ridge_idx = np.nonzero(kind == 0)[0]
|
||||
valley_idx = np.nonzero(kind == 1)[0]
|
||||
if len(ridge_idx) == 0 or len(valley_idx) == 0:
|
||||
return adj
|
||||
|
||||
valley_tree = cKDTree(pos[valley_idx, :2])
|
||||
for ri in ridge_idx:
|
||||
cand = valley_tree.query_ball_point(pos[ri, :2], MAX_EDGE_LEN_M)
|
||||
cand = sorted(
|
||||
cand,
|
||||
key=lambda j: (
|
||||
(pos[ri, 0] - pos[valley_idx[j], 0]) ** 2
|
||||
+ (pos[ri, 1] - pos[valley_idx[j], 1]) ** 2
|
||||
),
|
||||
)
|
||||
added = 0
|
||||
for j in cand:
|
||||
vi = int(valley_idx[j])
|
||||
length = math.hypot(pos[ri, 0] - pos[vi, 0], pos[ri, 1] - pos[vi, 1])
|
||||
if length < MIN_EDGE_LEN_M:
|
||||
continue
|
||||
if pos[ri, 2] <= pos[vi, 2]:
|
||||
continue
|
||||
if not _segment_feasible(
|
||||
pos[ri],
|
||||
pos[vi],
|
||||
grid,
|
||||
barrier,
|
||||
blocked_circles,
|
||||
min_grade,
|
||||
max_grade,
|
||||
tol,
|
||||
endpoint_free_m,
|
||||
):
|
||||
continue
|
||||
adj[int(ri)].append((vi, length))
|
||||
adj[vi].append((int(ri), length))
|
||||
added += 1
|
||||
if added >= MAX_NEIGHBORS_PER_NODE:
|
||||
break
|
||||
return adj
|
||||
|
||||
|
||||
def _endpoint_connectors(
|
||||
pt: dict[str, float],
|
||||
pos: np.ndarray,
|
||||
grid: _Grid,
|
||||
barrier: np.ndarray,
|
||||
blocked_circles: list[dict[str, float]],
|
||||
max_uphill_grade: float,
|
||||
max_downhill_grade: float,
|
||||
tol: float,
|
||||
endpoint_free_m: float,
|
||||
) -> list[tuple[int, float]]:
|
||||
"""BP/CP/EP를 그래프 노드에 잇는 연결 세그먼트 후보."""
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
if len(pos) == 0:
|
||||
return []
|
||||
p = np.array([pt["x"], pt["y"], grid.z_at(pt["x"], pt["y"])])
|
||||
tree = cKDTree(pos[:, :2])
|
||||
cand = tree.query_ball_point(p[:2], MAX_EDGE_LEN_M)
|
||||
cand = sorted(cand, key=lambda j: (p[0] - pos[j, 0]) ** 2 + (p[1] - pos[j, 1]) ** 2)
|
||||
out = []
|
||||
for j in cand:
|
||||
length = math.hypot(p[0] - pos[j, 0], p[1] - pos[j, 1])
|
||||
if length < 1e-6:
|
||||
out.append((int(j), max(length, 0.01)))
|
||||
continue
|
||||
applicable = max_uphill_grade if pos[j, 2] > p[2] else max_downhill_grade
|
||||
if _segment_feasible(
|
||||
p,
|
||||
pos[j],
|
||||
grid,
|
||||
barrier,
|
||||
blocked_circles,
|
||||
0.0,
|
||||
applicable,
|
||||
tol,
|
||||
endpoint_free_m,
|
||||
enforce_grade_window=False,
|
||||
):
|
||||
out.append((int(j), length))
|
||||
if len(out) >= MAX_NEIGHBORS_PER_NODE:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _turn_angle(p_prev, p_curr, p_next) -> float:
|
||||
"""진행방향 변화(교각) [rad]. 0 = 직진."""
|
||||
v1 = (p_curr[0] - p_prev[0], p_curr[1] - p_prev[1])
|
||||
v2 = (p_next[0] - p_curr[0], p_next[1] - p_curr[1])
|
||||
n1, n2 = math.hypot(*v1), math.hypot(*v2)
|
||||
if n1 < 1e-9 or n2 < 1e-9:
|
||||
return 0.0
|
||||
cosang = max(-1.0, min(1.0, (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2)))
|
||||
return math.acos(cosang)
|
||||
|
||||
|
||||
def _search_segment(
|
||||
start_pt: dict[str, float],
|
||||
end_pt: dict[str, float],
|
||||
pos: np.ndarray,
|
||||
adj: dict[int, list[tuple[int, float]]],
|
||||
grid: _Grid,
|
||||
barrier: np.ndarray,
|
||||
blocked_circles: list[dict[str, float]],
|
||||
max_uphill_grade: float,
|
||||
max_downhill_grade: float,
|
||||
tol: float,
|
||||
endpoint_free_m: float,
|
||||
min_radius: float,
|
||||
) -> list[list[float]] | None:
|
||||
"""start→end를 그래프 위에서 탐색해 노드 좌표 시퀀스를 반환 (실패 시 None)."""
|
||||
start_xyz = [start_pt["x"], start_pt["y"], grid.z_at(start_pt["x"], start_pt["y"])]
|
||||
end_xyz = [end_pt["x"], end_pt["y"], grid.z_at(end_pt["x"], end_pt["y"])]
|
||||
|
||||
direct_limit = max_uphill_grade if end_xyz[2] > start_xyz[2] else max_downhill_grade
|
||||
if _segment_feasible(
|
||||
np.asarray(start_xyz),
|
||||
np.asarray(end_xyz),
|
||||
grid,
|
||||
barrier,
|
||||
blocked_circles,
|
||||
0.0,
|
||||
direct_limit,
|
||||
tol,
|
||||
endpoint_free_m,
|
||||
enforce_grade_window=False,
|
||||
):
|
||||
return [start_xyz, end_xyz]
|
||||
|
||||
start_conn = _endpoint_connectors(
|
||||
start_pt,
|
||||
pos,
|
||||
grid,
|
||||
barrier,
|
||||
blocked_circles,
|
||||
max_uphill_grade,
|
||||
max_downhill_grade,
|
||||
tol,
|
||||
endpoint_free_m,
|
||||
)
|
||||
end_conn = _endpoint_connectors(
|
||||
end_pt,
|
||||
pos,
|
||||
grid,
|
||||
barrier,
|
||||
blocked_circles,
|
||||
max_uphill_grade,
|
||||
max_downhill_grade,
|
||||
tol,
|
||||
endpoint_free_m,
|
||||
)
|
||||
if not start_conn or not end_conn:
|
||||
return None
|
||||
end_conn_map = {j: length for j, length in end_conn}
|
||||
|
||||
START, MAX_TURN = -1, math.radians(MAX_TURN_DEG)
|
||||
|
||||
def _xy(i):
|
||||
return start_xyz if i == START else pos[i]
|
||||
|
||||
def _seg_len(i, j):
|
||||
a, b = _xy(i), _xy(j)
|
||||
return math.hypot(a[0] - b[0], a[1] - b[1])
|
||||
|
||||
def _fillet_ok(theta, len_in, len_out):
|
||||
if theta < 1e-6:
|
||||
return True
|
||||
t_len = min_radius * math.tan(theta / 2.0)
|
||||
return t_len <= len_in / 2.0 and t_len <= len_out / 2.0
|
||||
|
||||
dist: dict[tuple[int, int], float] = {}
|
||||
parent: dict[tuple[int, int], tuple[int, int]] = {}
|
||||
pq: list[tuple[float, int, int]] = []
|
||||
for j, length in start_conn:
|
||||
dist[(START, j)] = length
|
||||
heapq.heappush(pq, (length, START, j))
|
||||
|
||||
best_state, best_cost = None, float("inf")
|
||||
while pq:
|
||||
d, u, v = heapq.heappop(pq)
|
||||
if d > dist.get((u, v), float("inf")):
|
||||
continue
|
||||
if v in end_conn_map:
|
||||
theta = _turn_angle(_xy(u), _xy(v), end_xyz)
|
||||
if theta <= MAX_TURN and _fillet_ok(theta, _seg_len(u, v), end_conn_map[v]):
|
||||
total = d + end_conn_map[v] + TURN_PENALTY_W * theta
|
||||
if total < best_cost:
|
||||
best_cost, best_state = total, (u, v)
|
||||
for w, length in adj.get(v, []):
|
||||
if w == u:
|
||||
continue
|
||||
theta = _turn_angle(_xy(u), _xy(v), pos[w])
|
||||
if theta > MAX_TURN:
|
||||
continue
|
||||
if not _fillet_ok(theta, _seg_len(u, v), length):
|
||||
continue
|
||||
nd = d + length + TURN_PENALTY_W * theta
|
||||
if nd < dist.get((v, w), float("inf")):
|
||||
dist[(v, w)] = nd
|
||||
parent[(v, w)] = (u, v)
|
||||
heapq.heappush(pq, (nd, v, w))
|
||||
|
||||
if best_state is None:
|
||||
return None
|
||||
seq = [end_xyz]
|
||||
state = best_state
|
||||
while state is not None:
|
||||
u, v = state
|
||||
seq.append([float(pos[v][0]), float(pos[v][1]), float(pos[v][2])])
|
||||
state = parent.get(state)
|
||||
if state is None and u == START:
|
||||
break
|
||||
seq.append(start_xyz)
|
||||
return seq[::-1]
|
||||
|
||||
|
||||
def _fillet_alignment(
|
||||
nodes: list[list[float]], radius: float, step_m: float
|
||||
) -> tuple[list[list[float]], list[float]]:
|
||||
"""노드 시퀀스를 직선 + 최소회전반경 원호(fillet) 선형으로 변환한다."""
|
||||
n = len(nodes)
|
||||
if n < 2:
|
||||
return [list(p) for p in nodes], [float("inf")] * n
|
||||
|
||||
seg_len, seg_grade = [], []
|
||||
for i in range(n - 1):
|
||||
length = math.hypot(nodes[i + 1][0] - nodes[i][0], nodes[i + 1][1] - nodes[i][1])
|
||||
seg_len.append(max(length, 1e-9))
|
||||
seg_grade.append((nodes[i + 1][2] - nodes[i][2]) / max(length, 1e-9))
|
||||
|
||||
t_len = [0.0] * n
|
||||
theta = [0.0] * n
|
||||
for i in range(1, n - 1):
|
||||
th = _turn_angle(nodes[i - 1], nodes[i], nodes[i + 1])
|
||||
theta[i] = th
|
||||
if th < 1e-6:
|
||||
continue
|
||||
t = radius * math.tan(th / 2.0)
|
||||
t_len[i] = min(t, seg_len[i - 1] / 2.0, seg_len[i] / 2.0)
|
||||
|
||||
poly: list[list[float]] = []
|
||||
radii: list[float] = []
|
||||
|
||||
def _append(pt, rad):
|
||||
poly.append([float(pt[0]), float(pt[1]), float(pt[2])])
|
||||
radii.append(rad)
|
||||
|
||||
_append(nodes[0], float("inf"))
|
||||
for i in range(n - 1):
|
||||
ax, ay, az = nodes[i]
|
||||
bx, by, bz = nodes[i + 1]
|
||||
length, g = seg_len[i], seg_grade[i]
|
||||
ux, uy = (bx - ax) / length, (by - ay) / length
|
||||
s0, s1 = t_len[i], length - t_len[i + 1]
|
||||
n_pts = max(int((s1 - s0) / step_m), 1)
|
||||
for k in range(n_pts + 1):
|
||||
s = s0 + (s1 - s0) * (k / n_pts)
|
||||
_append((ax + ux * s, ay + uy * s, az + g * s), float("inf"))
|
||||
if i < n - 2 and t_len[i + 1] > 1e-9 and theta[i + 1] > 1e-6:
|
||||
th = theta[i + 1]
|
||||
t = t_len[i + 1]
|
||||
eff_r = t / math.tan(th / 2.0)
|
||||
cx0, cy0 = bx - ux * t, by - uy * t
|
||||
nx_, ny_, _nz = nodes[i + 2]
|
||||
length2 = seg_len[i + 1]
|
||||
vx, vy = (nx_ - bx) / length2, (ny_ - by) / length2
|
||||
cx1, cy1 = bx + vx * t, by + vy * t
|
||||
z_in = az + g * (length - t)
|
||||
z_out = bz + seg_grade[i + 1] * t
|
||||
arc_len = eff_r * th
|
||||
n_arc = max(int(arc_len / step_m), 2)
|
||||
cross = ux * vy - uy * vx
|
||||
sign = 1.0 if cross >= 0 else -1.0
|
||||
ox, oy = cx0 + (-uy * sign) * eff_r, cy0 + (ux * sign) * eff_r
|
||||
ang0 = math.atan2(cy0 - oy, cx0 - ox)
|
||||
for k in range(1, n_arc):
|
||||
a = ang0 + sign * th * (k / n_arc)
|
||||
frac = k / n_arc
|
||||
_append(
|
||||
(
|
||||
ox + eff_r * math.cos(a),
|
||||
oy + eff_r * math.sin(a),
|
||||
z_in + (z_out - z_in) * frac,
|
||||
),
|
||||
eff_r,
|
||||
)
|
||||
_ = (cx1, cy1)
|
||||
_append(nodes[-1], float("inf"))
|
||||
|
||||
cleaned, cleaned_r = [poly[0]], [radii[0]]
|
||||
for p, r in zip(poly[1:], radii[1:]):
|
||||
if math.hypot(p[0] - cleaned[-1][0], p[1] - cleaned[-1][1]) > 0.05:
|
||||
cleaned.append(p)
|
||||
cleaned_r.append(r)
|
||||
if len(cleaned) >= 2:
|
||||
cleaned[-1] = poly[-1]
|
||||
return cleaned, cleaned_r
|
||||
|
||||
|
||||
def resolve_grade_bounds(options: dict[str, Any]) -> dict[str, float]:
|
||||
"""options에서 방향별 경사 상/하한을 해석한다."""
|
||||
|
||||
def _opt(key: str, default: float) -> float:
|
||||
v = options.get(key)
|
||||
return float(v) if v is not None else default
|
||||
|
||||
min_uphill_grade = _opt("min_uphill_grade", ROUTE_ALT_MIN_GRADE)
|
||||
min_downhill_grade = _opt("min_downhill_grade", ROUTE_ALT_MIN_GRADE)
|
||||
max_uphill_grade = _opt("max_uphill_grade", ROUTE_ALT_MAX_GRADE)
|
||||
max_downhill_grade = _opt("max_downhill_grade", ROUTE_ALT_MAX_GRADE)
|
||||
return {
|
||||
"min_uphill_grade": min_uphill_grade,
|
||||
"min_downhill_grade": min_downhill_grade,
|
||||
"max_uphill_grade": max_uphill_grade,
|
||||
"max_downhill_grade": max_downhill_grade,
|
||||
"min_grade_for_edges": max(min_uphill_grade, min_downhill_grade),
|
||||
"max_grade_for_edges": min(max_uphill_grade, max_downhill_grade),
|
||||
}
|
||||
|
||||
|
||||
def solve_ridge_valley_route(
|
||||
project_root: Path,
|
||||
filter_key: str,
|
||||
smooth: bool,
|
||||
points_data: dict[str, Any],
|
||||
options: dict[str, Any],
|
||||
method: str = "dtm",
|
||||
) -> dict[str, Any]:
|
||||
"""능선-계곡 정속경사 방식으로 BP→(CP…)→EP 경로를 계산한다."""
|
||||
project_root = Path(project_root)
|
||||
models_dir = project_root / _MODELS_SUBDIR
|
||||
|
||||
(x_coords, y_coords, z_grid, valid_mask, _dz_dx, _dz_dy, grid_res) = (
|
||||
_load_or_build_cost_surface(project_root, models_dir, filter_key, method, smooth)
|
||||
)
|
||||
grid = _Grid(x_coords, y_coords, z_grid, valid_mask, grid_res)
|
||||
|
||||
bp = points_data.get("bp")
|
||||
ep = points_data.get("ep")
|
||||
cp_list = sorted(points_data.get("cp", []), key=lambda x: x.get("order", 0))
|
||||
ap_list = points_data.get("ap", [])
|
||||
fp_list = points_data.get("fp", [])
|
||||
if not bp or not ep:
|
||||
raise ValueError("BP/EP 가 배치되어 있지 않습니다.")
|
||||
|
||||
skeleton = load_or_build_skeleton(project_root, filter_key, method, smooth)
|
||||
barrier = _build_barrier_mask(grid, skeleton)
|
||||
|
||||
gb = resolve_grade_bounds(options)
|
||||
min_uphill_grade = gb["min_uphill_grade"]
|
||||
min_downhill_grade = gb["min_downhill_grade"]
|
||||
max_uphill_grade = gb["max_uphill_grade"]
|
||||
max_downhill_grade = gb["max_downhill_grade"]
|
||||
max_grade = gb["max_grade_for_edges"]
|
||||
min_grade = gb["min_grade_for_edges"]
|
||||
tol = float(options.get("alt_grade_tolerance") or ROUTE_ALT_GRADE_TOLERANCE)
|
||||
min_curve_radius_m = options.get("min_curve_radius_m")
|
||||
if not min_curve_radius_m or min_curve_radius_m <= 0:
|
||||
grade_class = options.get("grade_class", ROUTE_DEFAULT_GRADE_CLASS)
|
||||
min_curve_radius_m = FOREST_ROAD_MIN_CURVE_R_M.get(grade_class, 12.0)
|
||||
min_curve_radius_m = float(min_curve_radius_m)
|
||||
endpoint_free_m = float(SKELETON_NODE_SPACING_M) * 1.5
|
||||
|
||||
blocked = list(fp_list)
|
||||
if not options.get("allow_avoid_pass_through", False):
|
||||
blocked = blocked + list(ap_list)
|
||||
|
||||
pos, kind, _on_main = _collect_nodes(skeleton, float(SKELETON_NODE_SPACING_M))
|
||||
adj = _build_edges(
|
||||
pos, kind, grid, barrier, blocked, min_grade, max_grade, tol, endpoint_free_m
|
||||
)
|
||||
|
||||
sequence = [bp] + cp_list + [ep]
|
||||
|
||||
def _label(idx):
|
||||
if idx == 0:
|
||||
return "BP"
|
||||
if idx == len(sequence) - 1:
|
||||
return "EP"
|
||||
return f"CP{sequence[idx].get('order', idx)}"
|
||||
|
||||
all_nodes: list[list[float]] = []
|
||||
node_seg_marks: list[int] = []
|
||||
for i in range(len(sequence) - 1):
|
||||
seg_nodes = _search_segment(
|
||||
sequence[i],
|
||||
sequence[i + 1],
|
||||
pos,
|
||||
adj,
|
||||
grid,
|
||||
barrier,
|
||||
blocked,
|
||||
max_uphill_grade,
|
||||
max_downhill_grade,
|
||||
tol,
|
||||
endpoint_free_m,
|
||||
min_curve_radius_m,
|
||||
)
|
||||
if not seg_nodes:
|
||||
raise ValueError(
|
||||
f"세그먼트 {i + 1} ({_label(i)} -> {_label(i + 1)}) 능선-계곡 정속경사 경로 "
|
||||
f"탐색 실패: 오르막 {min_uphill_grade * 100:.0f}~{max_uphill_grade * 100:.0f}%·"
|
||||
f"내리막 {min_downhill_grade * 100:.0f}~{max_downhill_grade * 100:.0f}%·"
|
||||
f"허용오차 ±{tol * 100:.1f}%·최소곡선반지름 {min_curve_radius_m:.0f}m 제약으로 "
|
||||
f"성립하는 지능선-지계곡 연결이 없습니다."
|
||||
)
|
||||
if i == 0:
|
||||
all_nodes.extend(seg_nodes)
|
||||
else:
|
||||
all_nodes.extend(seg_nodes[1:])
|
||||
node_seg_marks.append(len(all_nodes) - 1)
|
||||
|
||||
polyline, _vertex_radii = _fillet_alignment(
|
||||
all_nodes, min_curve_radius_m, step_m=max(grid.res, 2.0)
|
||||
)
|
||||
|
||||
n = len(polyline)
|
||||
chainage_m = [0.0] * n
|
||||
length_m = 0.0
|
||||
max_grade_pct = 0.0
|
||||
max_uphill_pct = 0.0
|
||||
max_downhill_pct = 0.0
|
||||
grade_sums = 0.0
|
||||
slope_violations = 0
|
||||
for i in range(n - 1):
|
||||
x1, y1, z1 = polyline[i]
|
||||
x2, y2, z2 = polyline[i + 1]
|
||||
hd = math.hypot(x2 - x1, y2 - y1)
|
||||
chainage_m[i + 1] = chainage_m[i] + hd
|
||||
if hd > 0.01:
|
||||
dz = z2 - z1
|
||||
s = abs(dz) / hd
|
||||
length_m += hd
|
||||
grade_sums += s * hd
|
||||
max_grade_pct = max(max_grade_pct, s)
|
||||
if dz > 0:
|
||||
max_uphill_pct = max(max_uphill_pct, s)
|
||||
applicable = max_uphill_grade
|
||||
else:
|
||||
max_downhill_pct = max(max_downhill_pct, s)
|
||||
applicable = max_downhill_grade
|
||||
if s > applicable + tol:
|
||||
slope_violations += 1
|
||||
avg_grade_pct = (grade_sums / length_m) if length_m > 0 else 0.0
|
||||
|
||||
curve_check_step = max(2.0 * grid.res, 4.0)
|
||||
resampled = resample_polyline_2d(polyline, chainage_m, curve_check_step)
|
||||
total_chainage = chainage_m[-1] if chainage_m else 0.0
|
||||
curve_violations = 0
|
||||
min_curve_radius_actual = float("inf")
|
||||
radii = [float("inf")] * len(resampled)
|
||||
for i in range(1, len(resampled) - 1):
|
||||
radius = circumradius_2d(resampled[i - 1], resampled[i], resampled[i + 1])
|
||||
radii[i] = radius
|
||||
min_curve_radius_actual = min(min_curve_radius_actual, radius)
|
||||
if radius < min_curve_radius_m * 0.99:
|
||||
curve_violations += 1
|
||||
|
||||
curve_warning_segments = []
|
||||
run_start = None
|
||||
for i in range(1, len(resampled)):
|
||||
violating = i < len(resampled) - 1 and radii[i] < min_curve_radius_m * 0.99
|
||||
if violating and run_start is None:
|
||||
run_start = i
|
||||
if (not violating) and run_start is not None:
|
||||
run_end = i - 1
|
||||
ch_s = min(run_start * curve_check_step, total_chainage)
|
||||
ch_e = min(run_end * curve_check_step, total_chainage)
|
||||
curve_warning_segments.append(
|
||||
{
|
||||
"chainage_start_m": round(ch_s, 2),
|
||||
"chainage_end_m": round(ch_e, 2),
|
||||
"min_radius_m": round(min(radii[run_start : run_end + 1]), 2),
|
||||
"required_radius_m": round(min_curve_radius_m, 2),
|
||||
"polyline_start_index": int(np.searchsorted(chainage_m, ch_s)),
|
||||
"polyline_end_index": int(np.searchsorted(chainage_m, ch_e)),
|
||||
}
|
||||
)
|
||||
run_start = None
|
||||
|
||||
def _nearest_vertex(pt) -> int:
|
||||
best, best_d = 0, float("inf")
|
||||
for i, p in enumerate(polyline):
|
||||
d = (p[0] - pt[0]) ** 2 + (p[1] - pt[1]) ** 2
|
||||
if d < best_d:
|
||||
best, best_d = i, d
|
||||
return best
|
||||
|
||||
segments = []
|
||||
prev_idx = 0
|
||||
for si, mark in enumerate(node_seg_marks):
|
||||
end_idx = _nearest_vertex(all_nodes[mark])
|
||||
s, e = prev_idx, max(end_idx, prev_idx)
|
||||
seg_max_grade = 0.0
|
||||
for i in range(s, e):
|
||||
x1, y1, z1 = polyline[i]
|
||||
x2, y2, z2 = polyline[i + 1]
|
||||
hd = math.hypot(x2 - x1, y2 - y1)
|
||||
if hd > 0.01:
|
||||
seg_max_grade = max(seg_max_grade, abs(z2 - z1) / hd)
|
||||
segments.append(
|
||||
{
|
||||
"index": si,
|
||||
"from": _label(si),
|
||||
"to": _label(si + 1),
|
||||
"point_start": s,
|
||||
"point_end": e,
|
||||
"chainage_start_m": round(chainage_m[s], 2),
|
||||
"chainage_end_m": round(chainage_m[e], 2),
|
||||
"length_m": round(chainage_m[e] - chainage_m[s], 2),
|
||||
"max_grade_pct": round(seg_max_grade * 100, 2),
|
||||
}
|
||||
)
|
||||
prev_idx = e
|
||||
|
||||
tol_req = ROUTE_REQUIRED_POINT_TOLERANCE_M
|
||||
required_point_checks = []
|
||||
for idx, pt in enumerate(sequence):
|
||||
d = point_to_polyline_dist_2d(pt["x"], pt["y"], polyline)
|
||||
required_point_checks.append(
|
||||
{
|
||||
"point": _label(idx),
|
||||
"x": pt["x"],
|
||||
"y": pt["y"],
|
||||
"distance_m": round(d, 3),
|
||||
"snap_distance_m": 0.0,
|
||||
"point_on_valid_terrain": grid.valid_at(pt["x"], pt["y"]),
|
||||
"tolerance_m": tol_req,
|
||||
"within_tolerance": bool(d <= tol_req),
|
||||
}
|
||||
)
|
||||
required_points_ok = all(c["within_tolerance"] for c in required_point_checks)
|
||||
|
||||
avoid_intrusions = circle_intrusions(polyline, ap_list, lambda i: f"AP{i + 1}")
|
||||
forbidden_intrusions = circle_intrusions(polyline, fp_list, lambda i: f"FP{i + 1}")
|
||||
|
||||
constant_grade_segments = []
|
||||
for i in range(len(all_nodes) - 1):
|
||||
length = math.hypot(
|
||||
all_nodes[i + 1][0] - all_nodes[i][0], all_nodes[i + 1][1] - all_nodes[i][1]
|
||||
)
|
||||
if length > 0.01:
|
||||
constant_grade_segments.append(
|
||||
{
|
||||
"index": i,
|
||||
"length_m": round(length, 2),
|
||||
"grade_pct": round((all_nodes[i + 1][2] - all_nodes[i][2]) / length * 100, 2),
|
||||
}
|
||||
)
|
||||
|
||||
conditions_snapshot = {
|
||||
"filter": filter_key,
|
||||
"method": method,
|
||||
"smooth": smooth,
|
||||
"algorithm": "ridge_valley",
|
||||
"grade_class": options.get("grade_class", ROUTE_DEFAULT_GRADE_CLASS),
|
||||
"paved": bool(options.get("paved", False)),
|
||||
"max_uphill_grade_pct": round(max_uphill_grade * 100, 2),
|
||||
"max_downhill_grade_pct": round(max_downhill_grade * 100, 2),
|
||||
"min_uphill_grade_pct": round(min_uphill_grade * 100, 2),
|
||||
"min_downhill_grade_pct": round(min_downhill_grade * 100, 2),
|
||||
"grade_tolerance_pct": round(tol * 100, 2),
|
||||
"min_curve_radius_m": round(min_curve_radius_m, 2),
|
||||
"weights": options.get("weights") or {},
|
||||
"avoid_count": len(ap_list),
|
||||
"forbidden_count": len(fp_list),
|
||||
}
|
||||
|
||||
return {
|
||||
"polyline": polyline,
|
||||
"chainage_m": [round(v, 3) for v in chainage_m],
|
||||
"segments": segments,
|
||||
"required_point_checks": required_point_checks,
|
||||
"required_points_ok": required_points_ok,
|
||||
"avoid_intrusions": avoid_intrusions,
|
||||
"forbidden_intrusions": forbidden_intrusions,
|
||||
"curve_warning_segments": curve_warning_segments,
|
||||
"avoid_retry_performed": False,
|
||||
"conditions_snapshot": conditions_snapshot,
|
||||
"constant_grade_segments": constant_grade_segments,
|
||||
"metrics": {
|
||||
"length_m": round(length_m, 2),
|
||||
"avg_grade_pct": round(avg_grade_pct * 100, 2),
|
||||
"max_grade_pct": round(max_grade_pct * 100, 2),
|
||||
"max_uphill_pct": round(max_uphill_pct * 100, 2),
|
||||
"max_downhill_pct": round(max_downhill_pct * 100, 2),
|
||||
"slope_violations": slope_violations,
|
||||
"curve_violations": curve_violations,
|
||||
"min_curve_radius_m": round(min_curve_radius_actual, 2)
|
||||
if math.isfinite(min_curve_radius_actual)
|
||||
else None,
|
||||
"min_curve_radius_limit_m": round(min_curve_radius_m, 2),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"""B05 지형 스켈레톤(주/지 능선·계곡) 추출.
|
||||
|
||||
수문학적 정의: D8 흐름누적이 임계값 이상인 셀=계곡, DEM 반전 시 능선.
|
||||
누적값 크기의 2차 임계값으로 주/지를 나눈다. whitebox 우선, 실패 시 numpy
|
||||
D8 폴백. 산출 polyline은 비용면과 동일 좌표계이며 B05_wf2_Route/route에 캐시된다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Solver import (
|
||||
_MODELS_SUBDIR,
|
||||
_ROUTE_CACHE_SUBDIR,
|
||||
_cost_surface_signature,
|
||||
_load_or_build_cost_surface,
|
||||
)
|
||||
from config.config_system import (
|
||||
SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS,
|
||||
SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS,
|
||||
SKELETON_RIDGE_ACC_THRESHOLD_CELLS,
|
||||
SKELETON_VALLEY_ACC_THRESHOLD_CELLS,
|
||||
)
|
||||
|
||||
SKELETON_CLASSES = ("main_ridge", "minor_ridge", "main_valley", "minor_valley")
|
||||
|
||||
_D8_OFFSETS = [
|
||||
(-1, -1),
|
||||
(-1, 0),
|
||||
(-1, 1),
|
||||
(0, -1),
|
||||
(0, 1),
|
||||
(1, -1),
|
||||
(1, 0),
|
||||
(1, 1),
|
||||
]
|
||||
|
||||
|
||||
def _default_thresholds() -> dict[str, float]:
|
||||
return {
|
||||
"valley_acc": float(SKELETON_VALLEY_ACC_THRESHOLD_CELLS),
|
||||
"main_valley_acc": float(SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS),
|
||||
"ridge_acc": float(SKELETON_RIDGE_ACC_THRESHOLD_CELLS),
|
||||
"main_ridge_acc": float(SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS),
|
||||
}
|
||||
|
||||
|
||||
def d8_flow_accumulation_numpy(z_grid: np.ndarray, valid_mask: np.ndarray) -> np.ndarray:
|
||||
"""numpy 기반 D8 흐름누적 (whitebox 폴백)."""
|
||||
rows, cols = z_grid.shape
|
||||
n = rows * cols
|
||||
z_flat = z_grid.ravel()
|
||||
valid_flat = valid_mask.ravel()
|
||||
|
||||
receiver = np.full(n, -1, dtype=np.int64)
|
||||
for idx in range(n):
|
||||
if not valid_flat[idx]:
|
||||
continue
|
||||
r, c = divmod(idx, cols)
|
||||
zc = z_flat[idx]
|
||||
best_slope = 0.0
|
||||
best = -1
|
||||
for dr, dc in _D8_OFFSETS:
|
||||
nr, nc = r + dr, c + dc
|
||||
if not (0 <= nr < rows and 0 <= nc < cols):
|
||||
continue
|
||||
nidx = nr * cols + nc
|
||||
if not valid_flat[nidx]:
|
||||
continue
|
||||
drop = zc - z_flat[nidx]
|
||||
if drop <= 0:
|
||||
continue
|
||||
dist = math.sqrt(2.0) if (dr != 0 and dc != 0) else 1.0
|
||||
slope = drop / dist
|
||||
if slope > best_slope:
|
||||
best_slope = slope
|
||||
best = nidx
|
||||
receiver[idx] = best
|
||||
|
||||
acc = np.where(valid_flat, 1.0, 0.0)
|
||||
order = np.argsort(-z_flat, kind="stable")
|
||||
for idx in order:
|
||||
recv = receiver[idx]
|
||||
if recv >= 0 and valid_flat[idx]:
|
||||
acc[recv] += acc[idx]
|
||||
return acc.reshape(rows, cols)
|
||||
|
||||
|
||||
def _d8_flow_accumulation_whitebox(
|
||||
z_grid: np.ndarray,
|
||||
x_coords: np.ndarray,
|
||||
y_coords: np.ndarray,
|
||||
valid_mask: np.ndarray,
|
||||
grid_res: float,
|
||||
) -> np.ndarray | None:
|
||||
"""WhiteboxTools로 D8 흐름누적을 계산한다 (실패 시 None → numpy 폴백)."""
|
||||
try:
|
||||
import rasterio
|
||||
from rasterio.transform import from_origin
|
||||
from whitebox import WhiteboxTools
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
nodata = -9999.0
|
||||
rows, cols = z_grid.shape
|
||||
z_out = np.where(valid_mask, z_grid, nodata).astype(np.float32)[::-1, :]
|
||||
transform = from_origin(
|
||||
float(x_coords[0]) - grid_res / 2.0,
|
||||
float(y_coords[-1]) + grid_res / 2.0,
|
||||
grid_res,
|
||||
grid_res,
|
||||
)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="wbt_skel_") as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
dem_tif = tmp_path / "dem.tif"
|
||||
acc_tif = tmp_path / "acc.tif"
|
||||
with rasterio.open(
|
||||
dem_tif,
|
||||
"w",
|
||||
driver="GTiff",
|
||||
height=rows,
|
||||
width=cols,
|
||||
count=1,
|
||||
dtype="float32",
|
||||
nodata=nodata,
|
||||
transform=transform,
|
||||
) as dst:
|
||||
dst.write(z_out, 1)
|
||||
|
||||
wbt = WhiteboxTools()
|
||||
wbt.set_verbose_mode(False)
|
||||
wbt.set_working_dir(str(tmp_path))
|
||||
if wbt.fill_depressions("dem.tif", "filled.tif") != 0:
|
||||
raise RuntimeError("fill_depressions 실패")
|
||||
if wbt.d8_flow_accumulation("filled.tif", "acc.tif", out_type="cells") != 0:
|
||||
raise RuntimeError("d8_flow_accumulation 실패")
|
||||
|
||||
with rasterio.open(acc_tif) as src:
|
||||
acc = src.read(1).astype(np.float64)[::-1, :]
|
||||
return np.where(np.isfinite(acc) & (acc > 0) & valid_mask, acc, 0.0)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _flow_accumulation(
|
||||
z_grid: np.ndarray,
|
||||
x_coords: np.ndarray,
|
||||
y_coords: np.ndarray,
|
||||
valid_mask: np.ndarray,
|
||||
grid_res: float,
|
||||
) -> np.ndarray:
|
||||
acc = _d8_flow_accumulation_whitebox(z_grid, x_coords, y_coords, valid_mask, grid_res)
|
||||
if acc is None:
|
||||
acc = d8_flow_accumulation_numpy(z_grid, valid_mask)
|
||||
return acc
|
||||
|
||||
|
||||
def _trace_polylines(mask: np.ndarray) -> list[list[tuple[int, int]]]:
|
||||
"""1픽셀 폭 스켈레톤 마스크를 (r, c) polyline 목록으로 변환한다."""
|
||||
pixels = set(zip(*np.nonzero(mask)))
|
||||
if not pixels:
|
||||
return []
|
||||
|
||||
def neighbors(p):
|
||||
r, c = p
|
||||
return [(r + dr, c + dc) for dr, dc in _D8_OFFSETS if (r + dr, c + dc) in pixels]
|
||||
|
||||
degree = {p: len(neighbors(p)) for p in pixels}
|
||||
seeds = [p for p in pixels if degree[p] != 2]
|
||||
visited_edges: set = set()
|
||||
polylines: list[list[tuple[int, int]]] = []
|
||||
|
||||
def edge_key(a, b):
|
||||
return (a, b) if a <= b else (b, a)
|
||||
|
||||
def walk(start, nxt):
|
||||
path = [start, nxt]
|
||||
visited_edges.add(edge_key(start, nxt))
|
||||
prev, curr = start, nxt
|
||||
while degree[curr] == 2:
|
||||
candidates = [q for q in neighbors(curr) if q != prev]
|
||||
if not candidates:
|
||||
break
|
||||
q = candidates[0]
|
||||
if edge_key(curr, q) in visited_edges:
|
||||
break
|
||||
visited_edges.add(edge_key(curr, q))
|
||||
path.append(q)
|
||||
prev, curr = curr, q
|
||||
return path
|
||||
|
||||
for seed in seeds:
|
||||
for nb in neighbors(seed):
|
||||
if edge_key(seed, nb) not in visited_edges:
|
||||
polylines.append(walk(seed, nb))
|
||||
|
||||
for p in pixels:
|
||||
if degree[p] == 2:
|
||||
for nb in neighbors(p):
|
||||
if edge_key(p, nb) not in visited_edges:
|
||||
polylines.append(walk(p, nb))
|
||||
|
||||
return [pl for pl in polylines if len(pl) >= 2]
|
||||
|
||||
|
||||
def _mask_to_polylines(
|
||||
mask: np.ndarray, x_coords: np.ndarray, y_coords: np.ndarray, z_grid: np.ndarray
|
||||
) -> list[dict[str, Any]]:
|
||||
"""셀 마스크를 세선화한 뒤 모델좌표 polyline 목록으로 변환한다."""
|
||||
if not mask.any():
|
||||
return []
|
||||
try:
|
||||
from skimage.morphology import skeletonize
|
||||
|
||||
skel = skeletonize(mask)
|
||||
except Exception:
|
||||
skel = mask
|
||||
out = []
|
||||
for pixel_path in _trace_polylines(skel):
|
||||
poly = [
|
||||
[float(x_coords[c]), float(y_coords[r]), float(z_grid[r, c])] for r, c in pixel_path
|
||||
]
|
||||
out.append({"polyline": poly})
|
||||
return out
|
||||
|
||||
|
||||
def extract_skeleton_from_grid(
|
||||
x_coords: np.ndarray,
|
||||
y_coords: np.ndarray,
|
||||
z_grid: np.ndarray,
|
||||
valid_mask: np.ndarray,
|
||||
grid_res: float,
|
||||
thresholds: dict[str, float] | None = None,
|
||||
use_whitebox: bool = True,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""격자에서 주/지 능선·계곡 polyline을 추출한다 (캐시 없음, 테스트용 공개 API)."""
|
||||
th = thresholds or _default_thresholds()
|
||||
|
||||
if use_whitebox:
|
||||
acc_valley = _flow_accumulation(z_grid, x_coords, y_coords, valid_mask, grid_res)
|
||||
acc_ridge = _flow_accumulation(-z_grid, x_coords, y_coords, valid_mask, grid_res)
|
||||
else:
|
||||
acc_valley = d8_flow_accumulation_numpy(z_grid, valid_mask)
|
||||
acc_ridge = d8_flow_accumulation_numpy(-z_grid, valid_mask)
|
||||
|
||||
valley_mask = valid_mask & (acc_valley >= th["valley_acc"])
|
||||
main_valley_mask = valley_mask & (acc_valley >= th["main_valley_acc"])
|
||||
minor_valley_mask = valley_mask & ~main_valley_mask
|
||||
|
||||
ridge_mask = valid_mask & ~valley_mask & (acc_ridge >= th["ridge_acc"])
|
||||
main_ridge_mask = ridge_mask & (acc_ridge >= th["main_ridge_acc"])
|
||||
minor_ridge_mask = ridge_mask & ~main_ridge_mask
|
||||
|
||||
return {
|
||||
"main_ridge": _mask_to_polylines(main_ridge_mask, x_coords, y_coords, z_grid),
|
||||
"minor_ridge": _mask_to_polylines(minor_ridge_mask, x_coords, y_coords, z_grid),
|
||||
"main_valley": _mask_to_polylines(main_valley_mask, x_coords, y_coords, z_grid),
|
||||
"minor_valley": _mask_to_polylines(minor_valley_mask, x_coords, y_coords, z_grid),
|
||||
}
|
||||
|
||||
|
||||
def _skeleton_signature(models_dir: Path, filter_key: str, method: str, smooth: bool) -> str:
|
||||
"""소스 모델 + 격자 해상도 + 분류 임계값 서명."""
|
||||
th = _default_thresholds()
|
||||
th_part = "|".join(f"{k}={v}" for k, v in sorted(th.items()))
|
||||
return _cost_surface_signature(models_dir, filter_key, method, smooth) + "|" + th_part
|
||||
|
||||
|
||||
def load_or_build_skeleton(
|
||||
project_root: Path, filter_key: str, method: str, smooth: bool
|
||||
) -> dict[str, Any]:
|
||||
"""스켈레톤을 캐시에서 로드하거나 새로 계산해 저장한다."""
|
||||
project_root = Path(project_root)
|
||||
models_dir = project_root / _MODELS_SUBDIR
|
||||
cache_dir = project_root / _ROUTE_CACHE_SUBDIR
|
||||
suffix = "_smooth" if smooth else ""
|
||||
cache_path = cache_dir / f"terrain_skeleton_{filter_key}_{method}{suffix}.json"
|
||||
signature = _skeleton_signature(models_dir, filter_key, method, smooth)
|
||||
|
||||
if cache_path.exists():
|
||||
try:
|
||||
with open(cache_path, encoding="utf-8") as f:
|
||||
cached = json.load(f)
|
||||
if cached.get("signature") == signature:
|
||||
return cached
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_t0 = time.time()
|
||||
(x_coords, y_coords, z_grid, valid_mask, _dz_dx, _dz_dy, grid_res) = (
|
||||
_load_or_build_cost_surface(project_root, models_dir, filter_key, method, smooth)
|
||||
)
|
||||
z_grid = np.asarray(z_grid, dtype=np.float64)
|
||||
valid_mask = np.asarray(valid_mask, dtype=bool)
|
||||
|
||||
skeleton = extract_skeleton_from_grid(
|
||||
np.asarray(x_coords), np.asarray(y_coords), z_grid, valid_mask, float(grid_res)
|
||||
)
|
||||
result: dict[str, Any] = dict(skeleton)
|
||||
result["grid_res"] = float(grid_res)
|
||||
result["signature"] = signature
|
||||
|
||||
try:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
@@ -0,0 +1,656 @@
|
||||
"""B05 최적 경로 탐색 오케스트레이터.
|
||||
|
||||
확정된 지표면 모델(B04_wf1_Surface/models)의 표고를 DTM 격자에 샘플링해
|
||||
비용면을 만들고(캐시 재사용), BP→CP…→EP 다구간 Dijkstra로 최적 경로를
|
||||
계산한 뒤 평활·측점·곡률·제약검증 메트릭을 반환한다.
|
||||
"""
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Geometry import (
|
||||
circle_intrusions,
|
||||
circumradius_2d,
|
||||
compute_gradients,
|
||||
point_to_polyline_dist_2d,
|
||||
resample_polyline_2d,
|
||||
single_segment_dijkstra,
|
||||
)
|
||||
from config.config_system import (
|
||||
FOREST_ROAD_MAX_GRADE,
|
||||
FOREST_ROAD_MIN_CURVE_R_M,
|
||||
ROUTE_DEFAULT_GRADE_CLASS,
|
||||
ROUTE_GRID_RES_M,
|
||||
ROUTE_MAX_COST_CELLS,
|
||||
ROUTE_MAX_GRADE,
|
||||
ROUTE_MAX_GRADE_PAVED,
|
||||
ROUTE_REQUIRED_POINT_TOLERANCE_M,
|
||||
ROUTE_W_AVOID,
|
||||
ROUTE_W_CURVE,
|
||||
ROUTE_W_DIST,
|
||||
ROUTE_W_GRADE,
|
||||
ROUTE_W_SIDE,
|
||||
ROUTE_WEIGHT_MAX,
|
||||
)
|
||||
|
||||
# B04 지표면 모델 폴더명 (비용면의 표고 원본)
|
||||
_MODELS_SUBDIR = Path("B04_wf1_Surface") / "models"
|
||||
# B05 비용면 캐시 폴더명
|
||||
_ROUTE_CACHE_SUBDIR = Path("B05_wf2_Route") / "route"
|
||||
|
||||
|
||||
def _load_dtm_grid(models_dir: Path, filter_key: str, smooth: bool):
|
||||
"""필터의 정규 DTM 격자(x, y, z, valid_mask)를 로드한다."""
|
||||
suffix = "_smooth" if smooth else ""
|
||||
dtm_path = models_dir / f"dtm_{filter_key}{suffix}.npz"
|
||||
if not dtm_path.exists():
|
||||
dtm_path = models_dir / f"dtm_{filter_key}.npz"
|
||||
if not dtm_path.exists():
|
||||
raise FileNotFoundError(f"DTM 파일을 찾을 수 없습니다: dtm_{filter_key}{suffix}.npz")
|
||||
d = np.load(dtm_path)
|
||||
return (
|
||||
np.asarray(d["x"]),
|
||||
np.asarray(d["y"]),
|
||||
np.asarray(d["z"], dtype=np.float64),
|
||||
np.asarray(d["valid_mask"]),
|
||||
)
|
||||
|
||||
|
||||
def _sample_surface_on_grid(
|
||||
models_dir: Path,
|
||||
filter_key: str,
|
||||
method: str,
|
||||
smooth: bool,
|
||||
x_coords: np.ndarray,
|
||||
y_coords: np.ndarray,
|
||||
dtm_z: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""확정 지표면 모델의 표고를 DTM 격자에 샘플링한다(실패 시 DTM으로 폴백)."""
|
||||
if method == "dtm":
|
||||
return dtm_z
|
||||
|
||||
models_dir = Path(models_dir)
|
||||
xx, yy = np.meshgrid(x_coords, y_coords)
|
||||
query = np.column_stack([xx.ravel(), yy.ravel()])
|
||||
|
||||
def _finalize(z_flat: np.ndarray) -> np.ndarray:
|
||||
z = np.asarray(z_flat, dtype=np.float64).reshape(len(y_coords), len(x_coords))
|
||||
bad = ~np.isfinite(z)
|
||||
if bad.any():
|
||||
z[bad] = dtm_z[bad]
|
||||
return z
|
||||
|
||||
try:
|
||||
suffix = "_smooth" if smooth else ""
|
||||
if method == "tin":
|
||||
from scipy.interpolate import LinearNDInterpolator
|
||||
|
||||
path = models_dir / f"tin_{filter_key}{suffix}.npz"
|
||||
if not path.exists():
|
||||
path = models_dir / f"tin_{filter_key}.npz"
|
||||
d = np.load(path)
|
||||
verts = np.asarray(d["vertices"], dtype=np.float64)
|
||||
interp = LinearNDInterpolator(verts[:, :2], verts[:, 2])
|
||||
return _finalize(interp(query))
|
||||
|
||||
if method == "nurbs":
|
||||
from scipy.interpolate import RectBivariateSpline
|
||||
|
||||
d = np.load(models_dir / f"nurbs_{filter_key}.npz")
|
||||
cx = np.asarray(d["control_x"], dtype=np.float64)
|
||||
cy = np.asarray(d["control_y"], dtype=np.float64)
|
||||
cz = np.asarray(d["control_z"], dtype=np.float64)
|
||||
degree = int(d["degree"][0]) if "degree" in d else 3
|
||||
spline = RectBivariateSpline(
|
||||
cy, cx, cz, kx=min(degree, len(cy) - 1), ky=min(degree, len(cx) - 1)
|
||||
)
|
||||
return _finalize(spline(y_coords, x_coords).ravel())
|
||||
|
||||
if method == "implicit":
|
||||
from scipy.interpolate import RBFInterpolator
|
||||
|
||||
d = np.load(models_dir / f"implicit_{filter_key}.npz")
|
||||
centers = np.asarray(d["centers_xy"], dtype=np.float64)
|
||||
cz = np.asarray(d["center_z"], dtype=np.float64)
|
||||
smoothing = float(d["smoothing"][0]) if "smoothing" in d else 0.0
|
||||
interp = RBFInterpolator(
|
||||
centers,
|
||||
cz,
|
||||
neighbors=min(64, len(centers)),
|
||||
smoothing=smoothing,
|
||||
kernel="thin_plate_spline",
|
||||
)
|
||||
out = np.empty(len(query), dtype=np.float64)
|
||||
for s in range(0, len(query), 50_000):
|
||||
e = min(s + 50_000, len(query))
|
||||
out[s:e] = interp(query[s:e])
|
||||
return _finalize(out)
|
||||
|
||||
if method == "meshfree":
|
||||
from scipy.interpolate import griddata
|
||||
|
||||
d = np.load(models_dir / f"meshfree_{filter_key}.npz")
|
||||
pts = np.asarray(d["points"], dtype=np.float64)
|
||||
z = griddata(pts[:, :2], pts[:, 2], query, method="linear")
|
||||
return _finalize(z)
|
||||
except Exception:
|
||||
return dtm_z
|
||||
|
||||
return dtm_z
|
||||
|
||||
|
||||
def _source_npz_paths(models_dir: Path, filter_key: str, method: str, smooth: bool) -> list[Path]:
|
||||
"""비용면이 의존하는 소스 모델 파일(존재하는 것만, 안정 순서)."""
|
||||
suffix = "_smooth" if smooth else ""
|
||||
candidates = [
|
||||
models_dir / f"dtm_{filter_key}{suffix}.npz",
|
||||
models_dir / f"dtm_{filter_key}.npz",
|
||||
]
|
||||
if method != "dtm":
|
||||
candidates.append(models_dir / f"{method}_{filter_key}{suffix}.npz")
|
||||
candidates.append(models_dir / f"{method}_{filter_key}.npz")
|
||||
seen: set[Path] = set()
|
||||
out: list[Path] = []
|
||||
for p in candidates:
|
||||
if p.exists() and p not in seen:
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def _cost_surface_signature(models_dir: Path, filter_key: str, method: str, smooth: bool) -> str:
|
||||
"""비용면 재빌드 필요 시 바뀌는 서명(격자해상도 + 소스파일 mtime/size)."""
|
||||
parts = [f"res={ROUTE_GRID_RES_M}", f"method={method}", f"smooth={smooth}"]
|
||||
for p in _source_npz_paths(models_dir, filter_key, method, smooth):
|
||||
st = p.stat()
|
||||
parts.append(f"{p.name}:{int(st.st_mtime)}:{st.st_size}")
|
||||
return "|".join(parts)
|
||||
|
||||
|
||||
def _build_cost_surface(models_dir: Path, filter_key: str, method: str, smooth: bool):
|
||||
"""다운샘플된 비용면(좌표·표고·footprint·기울기)을 만든다."""
|
||||
x_full, y_full, dtm_z_full, valid_full = _load_dtm_grid(models_dir, filter_key, smooth)
|
||||
|
||||
target_res = ROUTE_GRID_RES_M
|
||||
src_res = (x_full[-1] - x_full[0]) / (len(x_full) - 1)
|
||||
step = max(1, int(round(target_res / src_res)))
|
||||
x_sub = np.ascontiguousarray(x_full[::step])
|
||||
y_sub = np.ascontiguousarray(y_full[::step])
|
||||
|
||||
n_cells = len(x_sub) * len(y_sub)
|
||||
if n_cells > ROUTE_MAX_COST_CELLS:
|
||||
raise ValueError(
|
||||
f"경로 비용면 격자 셀 수({n_cells:,})가 한도({ROUTE_MAX_COST_CELLS:,})를 "
|
||||
f"초과합니다. ROUTE_GRID_RES_M({target_res} m)를 키우거나 영역을 줄이세요."
|
||||
)
|
||||
|
||||
dtm_z_sub = np.array(dtm_z_full[::step, ::step], dtype=np.float64)
|
||||
valid_sub = np.array(valid_full[::step, ::step])
|
||||
del dtm_z_full, valid_full
|
||||
|
||||
z_sub = _sample_surface_on_grid(models_dir, filter_key, method, smooth, x_sub, y_sub, dtm_z_sub)
|
||||
z_sub = np.asarray(z_sub, dtype=np.float64)
|
||||
if not np.all(np.isfinite(z_sub)):
|
||||
finite = z_sub[np.isfinite(z_sub)]
|
||||
fill = float(finite.mean()) if finite.size else 0.0
|
||||
z_sub = np.where(np.isfinite(z_sub), z_sub, fill)
|
||||
|
||||
res_y = (y_sub[-1] - y_sub[0]) / (len(y_sub) - 1) if len(y_sub) > 1 else target_res
|
||||
res_x = (x_sub[-1] - x_sub[0]) / (len(x_sub) - 1) if len(x_sub) > 1 else target_res
|
||||
dz_dx, dz_dy = compute_gradients(z_sub, res_y, res_x)
|
||||
|
||||
grid_res = float(0.5 * (res_x + res_y))
|
||||
return x_sub, y_sub, z_sub, valid_sub, dz_dx, dz_dy, grid_res
|
||||
|
||||
|
||||
def _load_or_build_cost_surface(
|
||||
project_root: Path, models_dir: Path, filter_key: str, method: str, smooth: bool
|
||||
):
|
||||
"""비용면을 반환한다(서명 일치 시 캐시 재사용, 아니면 재빌드·재캐시)."""
|
||||
cache_dir = project_root / _ROUTE_CACHE_SUBDIR
|
||||
suffix = "_smooth" if smooth else ""
|
||||
cache_path = cache_dir / f"cost_surface_{filter_key}_{method}{suffix}.npz"
|
||||
signature = _cost_surface_signature(models_dir, filter_key, method, smooth)
|
||||
|
||||
if cache_path.exists():
|
||||
try:
|
||||
cached = np.load(cache_path, allow_pickle=False)
|
||||
if str(cached["signature"]) == signature:
|
||||
return (
|
||||
cached["x"],
|
||||
cached["y"],
|
||||
cached["z"],
|
||||
cached["valid_mask"],
|
||||
cached["dz_dx"],
|
||||
cached["dz_dy"],
|
||||
float(cached["target_res"][0]),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
surface = _build_cost_surface(models_dir, filter_key, method, smooth)
|
||||
x_sub, y_sub, z_sub, valid_sub, dz_dx, dz_dy, target_res = surface
|
||||
try:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
np.savez_compressed(
|
||||
cache_path,
|
||||
x=x_sub,
|
||||
y=y_sub,
|
||||
z=z_sub,
|
||||
valid_mask=valid_sub,
|
||||
dz_dx=dz_dx,
|
||||
dz_dy=dz_dy,
|
||||
target_res=np.array([target_res], np.float64),
|
||||
signature=np.array(signature),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return surface
|
||||
|
||||
|
||||
def _empty_route_result() -> dict[str, Any]:
|
||||
return {
|
||||
"polyline": [],
|
||||
"chainage_m": [],
|
||||
"segments": [],
|
||||
"required_point_checks": [],
|
||||
"required_points_ok": False,
|
||||
"avoid_intrusions": [],
|
||||
"forbidden_intrusions": [],
|
||||
"curve_warning_segments": [],
|
||||
"avoid_retry_performed": False,
|
||||
"conditions_snapshot": {},
|
||||
"metrics": {
|
||||
"length_m": 0.0,
|
||||
"avg_grade_pct": 0.0,
|
||||
"max_grade_pct": 0.0,
|
||||
"slope_violations": 0,
|
||||
"curve_violations": 0,
|
||||
"min_curve_radius_m": None,
|
||||
"min_curve_radius_limit_m": 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def solve_optimal_route(
|
||||
project_root: Path,
|
||||
filter_key: str,
|
||||
smooth: bool,
|
||||
points_data: dict[str, Any],
|
||||
options: dict[str, Any],
|
||||
method: str = "dtm",
|
||||
_avoid_retry: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""다구간 비용면 경로 탐색을 오케스트레이션해 최종 좌표·메트릭을 반환한다."""
|
||||
project_root = Path(project_root)
|
||||
models_dir = project_root / _MODELS_SUBDIR
|
||||
|
||||
(
|
||||
x_coords_sub,
|
||||
y_coords_sub,
|
||||
z_grid_sub,
|
||||
valid_mask_sub,
|
||||
dz_dx,
|
||||
dz_dy,
|
||||
target_res,
|
||||
) = _load_or_build_cost_surface(project_root, models_dir, filter_key, method, smooth)
|
||||
|
||||
bp = points_data.get("bp")
|
||||
ep = points_data.get("ep")
|
||||
cp_list = sorted(points_data.get("cp", []), key=lambda x: x.get("order", 0))
|
||||
ap_list = points_data.get("ap", [])
|
||||
fp_list = points_data.get("fp", [])
|
||||
|
||||
if fp_list:
|
||||
valid_mask_sub = np.array(valid_mask_sub, copy=True)
|
||||
xx, yy = np.meshgrid(x_coords_sub, y_coords_sub)
|
||||
for fp in fp_list:
|
||||
inside = (xx - fp["x"]) ** 2 + (yy - fp["y"]) ** 2 < float(fp["radius_m"]) ** 2
|
||||
valid_mask_sub[inside] = False
|
||||
|
||||
if not bp or not ep:
|
||||
return _empty_route_result()
|
||||
|
||||
sequence = [bp] + cp_list + [ep]
|
||||
|
||||
def _nearest_coord_index(coords: np.ndarray, value: float) -> int:
|
||||
upper = int(np.clip(np.searchsorted(coords, value), 0, len(coords) - 1))
|
||||
lower = max(upper - 1, 0)
|
||||
return lower if abs(value - coords[lower]) <= abs(coords[upper] - value) else upper
|
||||
|
||||
def get_grid_indices(pt: dict[str, float]) -> tuple[int, int, bool]:
|
||||
c = _nearest_coord_index(x_coords_sub, pt["x"])
|
||||
r = _nearest_coord_index(y_coords_sub, pt["y"])
|
||||
in_bounds = float(x_coords_sub[0]) <= pt["x"] <= float(x_coords_sub[-1]) and float(
|
||||
y_coords_sub[0]
|
||||
) <= pt["y"] <= float(y_coords_sub[-1])
|
||||
point_on_valid_terrain = in_bounds and bool(valid_mask_sub[r, c])
|
||||
if not point_on_valid_terrain:
|
||||
valid_ys, valid_xs = np.where(valid_mask_sub)
|
||||
if len(valid_ys) > 0:
|
||||
dists = (valid_ys - r) ** 2 + (valid_xs - c) ** 2
|
||||
best_idx = np.argmin(dists)
|
||||
r, c = int(valid_ys[best_idx]), int(valid_xs[best_idx])
|
||||
return r, c, point_on_valid_terrain
|
||||
|
||||
tol_req = ROUTE_REQUIRED_POINT_TOLERANCE_M
|
||||
required_snap = []
|
||||
for pt in sequence:
|
||||
r, c, point_on_valid_terrain = get_grid_indices(pt)
|
||||
sx, sy = float(x_coords_sub[c]), float(y_coords_sub[r])
|
||||
snap_dist = math.hypot(pt["x"] - sx, pt["y"] - sy)
|
||||
required_snap.append(
|
||||
{
|
||||
"r": r,
|
||||
"c": c,
|
||||
"snap_x": sx,
|
||||
"snap_y": sy,
|
||||
"snap_dist": snap_dist,
|
||||
"point_on_valid_terrain": point_on_valid_terrain,
|
||||
}
|
||||
)
|
||||
|
||||
weights = options.get("weights") or {
|
||||
"dist": ROUTE_W_DIST,
|
||||
"grade": ROUTE_W_GRADE,
|
||||
"side": ROUTE_W_SIDE,
|
||||
"curve": ROUTE_W_CURVE,
|
||||
"avoid": ROUTE_W_AVOID,
|
||||
}
|
||||
paved = options.get("paved", False)
|
||||
|
||||
grade_class = options.get("grade_class", ROUTE_DEFAULT_GRADE_CLASS)
|
||||
base_max_grade = FOREST_ROAD_MAX_GRADE.get(grade_class, ROUTE_MAX_GRADE)
|
||||
max_grade = max(base_max_grade, ROUTE_MAX_GRADE_PAVED) if paved else base_max_grade
|
||||
|
||||
min_curve_radius_m = options.get("min_curve_radius_m")
|
||||
if not min_curve_radius_m or min_curve_radius_m <= 0:
|
||||
min_curve_radius_m = FOREST_ROAD_MIN_CURVE_R_M.get(grade_class, 12.0)
|
||||
|
||||
def _grade_opt(key: str) -> float:
|
||||
v = options.get(key)
|
||||
return float(v) if (v is not None and float(v) > 0) else max_grade
|
||||
|
||||
max_uphill_grade = _grade_opt("max_uphill_grade")
|
||||
max_downhill_grade = _grade_opt("max_downhill_grade")
|
||||
|
||||
def _point_label(idx: int, pt: dict[str, Any]) -> str:
|
||||
if idx == 0:
|
||||
return "BP"
|
||||
if idx == len(sequence) - 1:
|
||||
return "EP"
|
||||
return f"CP{pt.get('order', idx)}"
|
||||
|
||||
full_path_grid: list[tuple[int, int]] = []
|
||||
segment_bounds: list[dict[str, Any]] = []
|
||||
|
||||
for i in range(len(sequence) - 1):
|
||||
pt_start = sequence[i]
|
||||
pt_end = sequence[i + 1]
|
||||
r_s, c_s, _ = get_grid_indices(pt_start)
|
||||
r_e, c_e, _ = get_grid_indices(pt_end)
|
||||
|
||||
segment = single_segment_dijkstra(
|
||||
r_s,
|
||||
c_s,
|
||||
r_e,
|
||||
c_e,
|
||||
x_coords_sub,
|
||||
y_coords_sub,
|
||||
z_grid_sub,
|
||||
valid_mask_sub,
|
||||
dz_dx,
|
||||
dz_dy,
|
||||
ap_list,
|
||||
weights,
|
||||
max_grade,
|
||||
target_res,
|
||||
min_curve_radius_m,
|
||||
max_uphill_grade,
|
||||
max_downhill_grade,
|
||||
)
|
||||
if not segment:
|
||||
fp_note = "·금지구역(FP)" if fp_list else ""
|
||||
raise ValueError(
|
||||
f"세그먼트 {i + 1} ({_point_label(i, pt_start)} -> {_point_label(i + 1, pt_end)}) "
|
||||
f"경로 탐색 실패: 종단경사 한계({max_grade * 100:.0f}%)·최소곡선반지름"
|
||||
f"({min_curve_radius_m:.0f}m)·회피지역{fp_note} 제약으로 통과 경로가 없습니다."
|
||||
)
|
||||
|
||||
start_idx = max(len(full_path_grid) - 1, 0)
|
||||
if i > 0 and len(segment) > 0:
|
||||
full_path_grid.extend(segment[1:])
|
||||
else:
|
||||
full_path_grid.extend(segment)
|
||||
segment_bounds.append(
|
||||
{
|
||||
"index": i,
|
||||
"from": _point_label(i, pt_start),
|
||||
"to": _point_label(i + 1, pt_end),
|
||||
"point_start": start_idx,
|
||||
"point_end": len(full_path_grid) - 1,
|
||||
}
|
||||
)
|
||||
|
||||
polyline = []
|
||||
for r, c in full_path_grid:
|
||||
polyline.append([float(x_coords_sub[c]), float(y_coords_sub[r]), float(z_grid_sub[r, c])])
|
||||
|
||||
def _grid_z(px: float, py: float) -> float:
|
||||
c_idx = _nearest_coord_index(x_coords_sub, px)
|
||||
r_idx = _nearest_coord_index(y_coords_sub, py)
|
||||
return float(z_grid_sub[r_idx, c_idx])
|
||||
|
||||
def _pin_coord_for(seq_idx: int) -> list[float]:
|
||||
snap = required_snap[seq_idx]
|
||||
pt = sequence[seq_idx]
|
||||
if snap["point_on_valid_terrain"]:
|
||||
return [pt["x"], pt["y"], _grid_z(pt["x"], pt["y"])]
|
||||
return [snap["snap_x"], snap["snap_y"], _grid_z(snap["snap_x"], snap["snap_y"])]
|
||||
|
||||
pin_coords: dict[int, list[float]] = {}
|
||||
for sb in segment_bounds:
|
||||
pin_coords[sb["point_start"]] = _pin_coord_for(sb["index"])
|
||||
pin_coords[sb["point_end"]] = _pin_coord_for(sb["index"] + 1)
|
||||
|
||||
if len(polyline) > 4:
|
||||
original = polyline
|
||||
smoothed_polyline = []
|
||||
window_size = 3
|
||||
padded = [original[0]] * (window_size // 2) + original + [original[-1]] * (window_size // 2)
|
||||
for i in range(len(original)):
|
||||
if i in pin_coords:
|
||||
smoothed_polyline.append(list(pin_coords[i]))
|
||||
continue
|
||||
window = padded[i : i + window_size]
|
||||
sx = sum(p[0] for p in window) / window_size
|
||||
sy = sum(p[1] for p in window) / window_size
|
||||
sz = sum(p[2] for p in window) / window_size
|
||||
smoothed_polyline.append([sx, sy, sz])
|
||||
polyline = smoothed_polyline
|
||||
else:
|
||||
for i, coord in pin_coords.items():
|
||||
if 0 <= i < len(polyline):
|
||||
polyline[i] = list(coord)
|
||||
|
||||
n = len(polyline)
|
||||
chainage_m = [0.0] * n
|
||||
length_m = 0.0
|
||||
slope_violations = 0
|
||||
max_grade_pct = 0.0
|
||||
grade_sums = 0.0
|
||||
max_uphill_pct = 0.0
|
||||
max_downhill_pct = 0.0
|
||||
for i in range(n - 1):
|
||||
x1, y1, z1 = polyline[i]
|
||||
x2, y2, z2 = polyline[i + 1]
|
||||
h_dist = math.hypot(x2 - x1, y2 - y1)
|
||||
chainage_m[i + 1] = chainage_m[i] + h_dist
|
||||
if h_dist > 0.01:
|
||||
dz = z2 - z1
|
||||
segment_slope = abs(dz) / h_dist
|
||||
length_m += h_dist
|
||||
grade_sums += segment_slope * h_dist
|
||||
max_grade_pct = max(max_grade_pct, segment_slope)
|
||||
applicable = max_uphill_grade if dz > 0 else max_downhill_grade
|
||||
if dz > 0:
|
||||
max_uphill_pct = max(max_uphill_pct, segment_slope)
|
||||
else:
|
||||
max_downhill_pct = max(max_downhill_pct, segment_slope)
|
||||
if segment_slope > applicable:
|
||||
slope_violations += 1
|
||||
|
||||
avg_grade_pct = (grade_sums / length_m) if length_m > 0 else 0.0
|
||||
|
||||
curve_check_step = max(2.0 * target_res, 4.0)
|
||||
resampled = resample_polyline_2d(polyline, chainage_m, curve_check_step)
|
||||
total_chainage = chainage_m[-1] if chainage_m else 0.0
|
||||
|
||||
def _poly_index_at_chainage(ch: float) -> int:
|
||||
idx = int(np.searchsorted(chainage_m, ch)) if chainage_m else 0
|
||||
return int(min(max(idx, 0), max(len(polyline) - 1, 0)))
|
||||
|
||||
curve_violations = 0
|
||||
min_curve_radius_actual = float("inf")
|
||||
radii = [float("inf")] * len(resampled)
|
||||
for i in range(1, len(resampled) - 1):
|
||||
radius = circumradius_2d(resampled[i - 1], resampled[i], resampled[i + 1])
|
||||
radii[i] = radius
|
||||
min_curve_radius_actual = min(min_curve_radius_actual, radius)
|
||||
if radius < min_curve_radius_m:
|
||||
curve_violations += 1
|
||||
|
||||
curve_warning_segments = []
|
||||
run_start = None
|
||||
for i in range(1, len(resampled)):
|
||||
violating = i < len(resampled) - 1 and radii[i] < min_curve_radius_m
|
||||
if violating and run_start is None:
|
||||
run_start = i
|
||||
if (not violating) and run_start is not None:
|
||||
run_end = i - 1
|
||||
ch_s = min(run_start * curve_check_step, total_chainage)
|
||||
ch_e = min(run_end * curve_check_step, total_chainage)
|
||||
curve_warning_segments.append(
|
||||
{
|
||||
"chainage_start_m": round(ch_s, 2),
|
||||
"chainage_end_m": round(ch_e, 2),
|
||||
"min_radius_m": round(min(radii[run_start : run_end + 1]), 2),
|
||||
"required_radius_m": round(min_curve_radius_m, 2),
|
||||
"polyline_start_index": _poly_index_at_chainage(ch_s),
|
||||
"polyline_end_index": _poly_index_at_chainage(ch_e),
|
||||
}
|
||||
)
|
||||
run_start = None
|
||||
|
||||
segments = []
|
||||
for sb in segment_bounds:
|
||||
s, e = sb["point_start"], min(sb["point_end"], n - 1)
|
||||
seg_max_grade = 0.0
|
||||
for i in range(s, e):
|
||||
x1, y1, z1 = polyline[i]
|
||||
x2, y2, z2 = polyline[i + 1]
|
||||
hd = math.hypot(x2 - x1, y2 - y1)
|
||||
if hd > 0.01:
|
||||
seg_max_grade = max(seg_max_grade, abs(z2 - z1) / hd)
|
||||
segments.append(
|
||||
{
|
||||
"index": sb["index"],
|
||||
"from": sb["from"],
|
||||
"to": sb["to"],
|
||||
"point_start": s,
|
||||
"point_end": e,
|
||||
"chainage_start_m": round(chainage_m[s], 2),
|
||||
"chainage_end_m": round(chainage_m[e], 2),
|
||||
"length_m": round(chainage_m[e] - chainage_m[s], 2),
|
||||
"max_grade_pct": round(seg_max_grade * 100, 2),
|
||||
}
|
||||
)
|
||||
|
||||
required_point_checks = []
|
||||
for idx, pt in enumerate(sequence):
|
||||
label = _point_label(idx, pt)
|
||||
poly_dist = point_to_polyline_dist_2d(pt["x"], pt["y"], polyline)
|
||||
snap = required_snap[idx]
|
||||
snap_dist = snap["snap_dist"]
|
||||
dist_val = poly_dist if snap["point_on_valid_terrain"] else max(poly_dist, snap_dist)
|
||||
required_point_checks.append(
|
||||
{
|
||||
"point": label,
|
||||
"x": pt["x"],
|
||||
"y": pt["y"],
|
||||
"distance_m": round(dist_val, 3),
|
||||
"snap_distance_m": round(snap_dist, 3),
|
||||
"point_on_valid_terrain": snap["point_on_valid_terrain"],
|
||||
"tolerance_m": tol_req,
|
||||
"within_tolerance": bool(dist_val <= tol_req),
|
||||
}
|
||||
)
|
||||
required_points_ok = all(c["within_tolerance"] for c in required_point_checks)
|
||||
|
||||
avoid_intrusions = circle_intrusions(polyline, ap_list, lambda i: f"AP{i + 1}")
|
||||
forbidden_intrusions = circle_intrusions(polyline, fp_list, lambda i: f"FP{i + 1}")
|
||||
allow_pass = bool(options.get("allow_avoid_pass_through", False))
|
||||
any_intrusion = any(a["intrudes"] for a in avoid_intrusions)
|
||||
if any_intrusion and not allow_pass and not _avoid_retry:
|
||||
boosted = dict(options)
|
||||
bw = dict(weights)
|
||||
bw["avoid"] = min(bw.get("avoid", ROUTE_W_AVOID) * 10.0, ROUTE_WEIGHT_MAX)
|
||||
boosted["weights"] = bw
|
||||
try:
|
||||
retried = solve_optimal_route(
|
||||
project_root,
|
||||
filter_key,
|
||||
smooth,
|
||||
points_data,
|
||||
boosted,
|
||||
method=method,
|
||||
_avoid_retry=True,
|
||||
)
|
||||
retried["avoid_retry_performed"] = True
|
||||
return retried
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conditions_snapshot = {
|
||||
"filter": filter_key,
|
||||
"method": method,
|
||||
"smooth": smooth,
|
||||
"grade_class": grade_class,
|
||||
"paved": paved,
|
||||
"max_grade_pct": round(max_grade * 100, 2),
|
||||
"max_uphill_grade_pct": round(max_uphill_grade * 100, 2),
|
||||
"max_downhill_grade_pct": round(max_downhill_grade * 100, 2),
|
||||
"min_curve_radius_m": round(min_curve_radius_m, 2),
|
||||
"weights": weights,
|
||||
"avoid_count": len(ap_list),
|
||||
"forbidden_count": len(fp_list),
|
||||
}
|
||||
|
||||
return {
|
||||
"polyline": polyline,
|
||||
"chainage_m": [round(v, 3) for v in chainage_m],
|
||||
"segments": segments,
|
||||
"required_point_checks": required_point_checks,
|
||||
"required_points_ok": required_points_ok,
|
||||
"avoid_intrusions": avoid_intrusions,
|
||||
"forbidden_intrusions": forbidden_intrusions,
|
||||
"curve_warning_segments": curve_warning_segments,
|
||||
"avoid_retry_performed": _avoid_retry,
|
||||
"conditions_snapshot": conditions_snapshot,
|
||||
"metrics": {
|
||||
"length_m": round(length_m, 2),
|
||||
"avg_grade_pct": round(avg_grade_pct * 100, 2),
|
||||
"max_grade_pct": round(max_grade_pct * 100, 2),
|
||||
"max_uphill_pct": round(max_uphill_pct * 100, 2),
|
||||
"max_downhill_pct": round(max_downhill_pct * 100, 2),
|
||||
"slope_violations": slope_violations,
|
||||
"curve_violations": curve_violations,
|
||||
"min_curve_radius_m": round(min_curve_radius_actual, 2)
|
||||
if math.isfinite(min_curve_radius_actual)
|
||||
else None,
|
||||
"min_curve_radius_limit_m": round(min_curve_radius_m, 2),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"""B05 경로 설계 결과의 aiomysql Raw SQL 접근.
|
||||
|
||||
routes(경로), route_points(렌더링 샘플), route_statistics(통계) 테이블에
|
||||
메타데이터와 상대 경로를 기록한다. 전체 폴리라인은 route_data_path의
|
||||
GeoJSON 파일에 저장하고 DB에는 경로만 기록한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
|
||||
_STAGE_ROOT = "B05_wf2_Route"
|
||||
|
||||
|
||||
def _validate_stage_path(relative_path: str) -> str:
|
||||
normalized = PurePosixPath(relative_path.replace("\\", "/"))
|
||||
if normalized.is_absolute() or ".." in normalized.parts:
|
||||
raise ValueError("DB에는 프로젝트 루트 기준 상대 경로만 저장할 수 있습니다.")
|
||||
if not normalized.parts or normalized.parts[0] != _STAGE_ROOT:
|
||||
raise ValueError(f"B05 산출물 경로는 {_STAGE_ROOT} 아래여야 합니다.")
|
||||
return normalized.as_posix()
|
||||
|
||||
|
||||
async def create_route(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
project_id: UUID,
|
||||
surface_model_id: int | None,
|
||||
total_length_m: float | None,
|
||||
start_chainage_m: float | None,
|
||||
end_chainage_m: float | None,
|
||||
grade_percent: list[float] | None,
|
||||
constraints: dict[str, Any] | None,
|
||||
algorithm_params: dict[str, Any] | None,
|
||||
route_data_path: str,
|
||||
status: str = "DRAFT",
|
||||
) -> int:
|
||||
"""경로 메타데이터를 저장하고 생성된 ID를 반환한다."""
|
||||
route_rel = _validate_stage_path(route_data_path)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO routes (
|
||||
project_id, surface_model_id, status,
|
||||
start_chainage_m, end_chainage_m, total_length_m,
|
||||
grade_percent, constraints, algorithm_params,
|
||||
route_data_path, computed_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(
|
||||
str(project_id),
|
||||
surface_model_id,
|
||||
status,
|
||||
start_chainage_m,
|
||||
end_chainage_m,
|
||||
total_length_m,
|
||||
json.dumps(grade_percent) if grade_percent is not None else None,
|
||||
json.dumps(constraints, ensure_ascii=False) if constraints is not None else None,
|
||||
json.dumps(algorithm_params, ensure_ascii=False)
|
||||
if algorithm_params is not None
|
||||
else None,
|
||||
route_rel,
|
||||
),
|
||||
)
|
||||
route_id = cursor.lastrowid
|
||||
if not route_id:
|
||||
raise RuntimeError("routes 레코드 생성 결과에 ID가 없습니다.")
|
||||
return int(route_id)
|
||||
|
||||
|
||||
async def insert_route_points(
|
||||
connection: aiomysql.Connection,
|
||||
route_id: int,
|
||||
points: list[dict[str, Any]],
|
||||
) -> int:
|
||||
"""경로 렌더링 샘플 포인트를 일괄 저장하고 저장 건수를 반환한다.
|
||||
|
||||
각 point dict: {chainage_m, elevation_m, slope_percent, sequence_num}
|
||||
"""
|
||||
if not points:
|
||||
return 0
|
||||
rows = [
|
||||
(
|
||||
route_id,
|
||||
point.get("chainage_m"),
|
||||
point.get("elevation_m"),
|
||||
point.get("slope_percent"),
|
||||
point.get("sequence_num"),
|
||||
)
|
||||
for point in points
|
||||
]
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.executemany(
|
||||
"""
|
||||
INSERT INTO route_points (
|
||||
route_id, chainage_m, elevation_m, slope_percent, sequence_num
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""",
|
||||
rows,
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def create_route_statistics(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
route_id: int,
|
||||
min_slope: float | None,
|
||||
max_slope: float | None,
|
||||
mean_slope: float | None,
|
||||
cut_volume_m3: float | None = None,
|
||||
fill_volume_m3: float | None = None,
|
||||
tree_cutting_volume: float | None = None,
|
||||
cost_score: float | None = None,
|
||||
) -> int:
|
||||
"""경로 통계를 저장하고 생성된 ID를 반환한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO route_statistics (
|
||||
route_id, min_slope, max_slope, mean_slope,
|
||||
cut_volume_m3, fill_volume_m3, tree_cutting_volume, cost_score
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
route_id,
|
||||
min_slope,
|
||||
max_slope,
|
||||
mean_slope,
|
||||
cut_volume_m3,
|
||||
fill_volume_m3,
|
||||
tree_cutting_volume,
|
||||
cost_score,
|
||||
),
|
||||
)
|
||||
stat_id = cursor.lastrowid
|
||||
if not stat_id:
|
||||
raise RuntimeError("route_statistics 레코드 생성 결과에 ID가 없습니다.")
|
||||
return int(stat_id)
|
||||
|
||||
|
||||
async def get_latest_route(
|
||||
connection: aiomysql.Connection, project_id: UUID
|
||||
) -> dict[str, Any] | None:
|
||||
"""프로젝트의 최신 경로를 조회한다 (없으면 None)."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT id, status, total_length_m, route_data_path, computed_at
|
||||
FROM routes
|
||||
WHERE project_id = %s
|
||||
ORDER BY computed_at DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": int(row[0]),
|
||||
"status": row[1],
|
||||
"total_length_m": row[2],
|
||||
"route_data_path": row[3],
|
||||
"computed_at": row[4].isoformat() if row[4] else None,
|
||||
}
|
||||
|
||||
|
||||
async def confirm_route(connection: aiomysql.Connection, route_id: int) -> None:
|
||||
"""경로 상태를 CONFIRMED로 변경한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"UPDATE routes SET status = 'CONFIRMED' WHERE id = %s",
|
||||
(route_id,),
|
||||
)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""B05 경로 설계 FastAPI 라우터."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design
|
||||
from B05_wf2_Route.B05_wf2_Route_Repository import (
|
||||
confirm_route,
|
||||
create_route,
|
||||
create_route_statistics,
|
||||
get_latest_route,
|
||||
insert_route_points,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Schema import (
|
||||
RouteConfirmResponse,
|
||||
RouteSolveRequest,
|
||||
RouteSolveResponse,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/solve", response_model=RouteSolveResponse)
|
||||
async def solve_route(
|
||||
project_id: UUID, request: RouteSolveRequest
|
||||
) -> RouteSolveResponse | JSONResponse:
|
||||
"""경로 탐색을 실행하고 결과를 GeoJSON 저장 + DB 기록한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
|
||||
# 무거운 경로 탐색은 이벤트 루프를 막지 않도록 별도 스레드에서 실행.
|
||||
design = await asyncio.to_thread(
|
||||
run_route_design,
|
||||
project_root,
|
||||
request.filter_key,
|
||||
request.method,
|
||||
request.smooth,
|
||||
request.points_data(),
|
||||
request.options(),
|
||||
request.algorithm,
|
||||
)
|
||||
solver = design["solver_result"]
|
||||
metrics = solver["metrics"]
|
||||
|
||||
await connection.begin()
|
||||
try:
|
||||
route_id = await create_route(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
surface_model_id=request.surface_model_id,
|
||||
total_length_m=metrics.get("length_m"),
|
||||
start_chainage_m=0.0,
|
||||
end_chainage_m=metrics.get("length_m"),
|
||||
grade_percent=design["grade_percent"],
|
||||
constraints=design["constraints"],
|
||||
algorithm_params=design["algorithm_params"],
|
||||
route_data_path=design["route_data_path"],
|
||||
)
|
||||
await insert_route_points(connection, route_id, design["render_points"])
|
||||
stats = design["statistics"]
|
||||
await create_route_statistics(
|
||||
connection,
|
||||
route_id=route_id,
|
||||
min_slope=stats["min_slope"],
|
||||
max_slope=stats["max_slope"],
|
||||
mean_slope=stats["mean_slope"],
|
||||
cost_score=stats["cost_score"],
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
return RouteSolveResponse(
|
||||
project_id=str(project_id),
|
||||
route_id=route_id,
|
||||
total_length_m=metrics.get("length_m", 0.0),
|
||||
metrics=metrics,
|
||||
required_points_ok=solver["required_points_ok"],
|
||||
route_data_path=design["route_data_path"],
|
||||
)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError) as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B05 경로 탐색 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "경로 탐색 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/confirm", response_model=RouteConfirmResponse)
|
||||
async def confirm_latest_route(project_id: UUID) -> RouteConfirmResponse | JSONResponse:
|
||||
"""프로젝트의 최신 경로를 확정(CONFIRMED)한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
latest = await get_latest_route(connection, project_id)
|
||||
if not latest:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "확정할 경로가 없습니다."},
|
||||
)
|
||||
await connection.begin()
|
||||
try:
|
||||
await confirm_route(connection, latest["id"])
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return RouteConfirmResponse(project_id=str(project_id), route_id=latest["id"])
|
||||
except Exception:
|
||||
logger.exception("B05 경로 확정 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "경로 확정 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""B05 경로 설계 요청·응답 검증 모델."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from config.config_system import ROUTE_GRADE_CLASSES
|
||||
|
||||
|
||||
class RoutePoint(BaseModel):
|
||||
"""경로 제어점 (BP/CP/EP)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
x: float
|
||||
y: float
|
||||
order: int | None = None
|
||||
|
||||
|
||||
class CirclePoint(BaseModel):
|
||||
"""회피/금지 원 (AP/FP)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
x: float
|
||||
y: float
|
||||
radius_m: float = Field(gt=0)
|
||||
|
||||
|
||||
class RouteSolveRequest(BaseModel):
|
||||
"""경로 탐색 실행 요청."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
filter_key: str = Field(description="지면 필터 키 (grid_min_z/csf/pmf)")
|
||||
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)")
|
||||
|
||||
bp: RoutePoint
|
||||
ep: RoutePoint
|
||||
cp: list[RoutePoint] = Field(default_factory=list)
|
||||
ap: list[CirclePoint] = Field(default_factory=list)
|
||||
fp: list[CirclePoint] = Field(default_factory=list)
|
||||
|
||||
grade_class: str = Field(default="trunk")
|
||||
paved: bool = Field(default=False)
|
||||
min_curve_radius_m: float | None = None
|
||||
max_uphill_grade: float | None = None
|
||||
max_downhill_grade: float | None = None
|
||||
weights: dict[str, float] | None = None
|
||||
allow_avoid_pass_through: bool = Field(default=False)
|
||||
|
||||
@model_validator(mode="after")
|
||||
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"):
|
||||
raise ValueError("경로 알고리즘은 dijkstra 또는 ridge_valley여야 합니다.")
|
||||
return self
|
||||
|
||||
def points_data(self) -> dict[str, Any]:
|
||||
return {
|
||||
"bp": self.bp.model_dump(),
|
||||
"ep": self.ep.model_dump(),
|
||||
"cp": [p.model_dump() for p in self.cp],
|
||||
"ap": [p.model_dump() for p in self.ap],
|
||||
"fp": [p.model_dump() for p in self.fp],
|
||||
}
|
||||
|
||||
def options(self) -> dict[str, Any]:
|
||||
return {
|
||||
"grade_class": self.grade_class,
|
||||
"paved": self.paved,
|
||||
"min_curve_radius_m": self.min_curve_radius_m,
|
||||
"max_uphill_grade": self.max_uphill_grade,
|
||||
"max_downhill_grade": self.max_downhill_grade,
|
||||
"weights": self.weights,
|
||||
"allow_avoid_pass_through": self.allow_avoid_pass_through,
|
||||
}
|
||||
|
||||
|
||||
class RouteSolveResponse(BaseModel):
|
||||
"""경로 탐색 실행 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
route_id: int
|
||||
total_length_m: float
|
||||
metrics: dict[str, Any]
|
||||
required_points_ok: bool
|
||||
route_data_path: str
|
||||
|
||||
|
||||
class RouteConfirmResponse(BaseModel):
|
||||
"""경로 확정 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
route_id: int
|
||||
confirmed: bool = True
|
||||
Reference in New Issue
Block a user