This commit is contained in:
2026-07-05 21:27:23 +09:00
parent 23d907265a
commit 3abc2edba6
83 changed files with 10351 additions and 1217 deletions
+120
View File
@@ -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),
},
}
+181
View File
@@ -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,),
)
+133
View File
@@ -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": "경로 확정 처리 중 오류가 발생했습니다."},
)
+102
View File
@@ -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