Files
Aislo/B05_Profile/B05_Profile_Engine_RidgeValley_Graph.py
eomsangdon acdd2cef4e refactor(B05): 능선-계곡 길찾기 그래프 뼈대 분리 (787→552줄)
- `B05_Profile_Engine_RidgeValley_Graph.py`(257줄) 신설 — 격자 조회(`_Grid`),
  노드 수집, 구획 마스크, 정속경사 세그먼트 판정, 엣지·연결 후보, 교각 계산.
- 원본에는 탐색(`_search_segment`)·fillet 선형·진입점 계산만 남김.
- 엣지 후보 상수(MAX_EDGE_LEN_M 등)도 함께 이동 — 쓰는 쪽이 전부 새 모듈.
- 외부 호출부 불변(`solve_ridge_valley_route` 경로 동일), pytest 359 passed.
2026-09-02 16:15:25 +09:00

258 lines
8.3 KiB
Python

"""B05 능선-계곡 길찾기 — 그래프 뼈대(격자 조회·노드·엣지).
`B05_Profile_Engine_RidgeValley` 에서 떼어낸 앞단이다(700줄 제한, 2026-09-02).
탐색·선형(fillet)·진입점 계산은 원래 파일에 남고, 여기에는 **비용면 격자 조회와
정속경사 세그먼트 판정·엣지 생성**만 둔다. 호출부는 원래 파일뿐이다.
"""
import math
from typing import Any
import numpy as np
# 엣지 후보 탐색 파라미터 (알고리즘 내부 상수)
MAX_EDGE_LEN_M = 400.0
MIN_EDGE_LEN_M = 20.0
MAX_NEIGHBORS_PER_NODE = 16
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)