diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index 44ac532e..5def0cb3 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -4,21 +4,24 @@ 받은 노드로 기존 `POST /route/replan`([확인])을 부른다. 갈래(`mode`): - · follow — 지금 로직 그대로: 예상노선 점 묶음을 폴리라인화(`build_planned_polyline`). - `B05_Profile_Router_Replan._ensure_planned_initial` 과 같은 입력 · 같은 결과. - · earthwork — 절 · 성토 합 최소(DEM 옆경사로 가늠한 단면 토량 + 상한 넘는 지반 기울기). + · follow — 예상노선 이격 최소(예상노선을 따르되 공통 제약은 지킴). + · earthwork — 절 · 성토 합 최소(DEM 옆경사로 가늠한 단면 토량). · gentle — 종단기울기 최소(지반 기울기 제곱 합). · weighted — 예상노선 이격 · 토공 · 기울기 세 목표를 **정규화해 가중합**(합 100). 정규화 = 목표마다 복도 안 링크 평균값으로 나눔 — 단위가 다른 세 값을 1 m 당 「보통 크기 1」로 맞춘 뒤 비중을 곱한다. -탐색 = 예상노선 둘레 복도(`CORRIDOR_M`) 안 `SEARCH_CELL_M` 격자의 8-연결 Dijkstra(scipy csgraph). -탐색 격자 경로는 follow 와 같은 폴리라인화(꺾임점 뽑기 + 원호 끼우기)를 지난다. +탐색 = 예상노선 둘레 복도(`CORRIDOR_M`) 안 `SEARCH_CELL_M` 격자 · 16 방향 Dijkstra(scipy csgraph). +상태 = (칸, 진행 방향, 꺾은 뒤 곧게 온 걸음 수). 탐색 경로의 꺾임점이 곧 노드이고, 노드마다 +최소 곡선반지름 원호를 끼운다(`build_planned_polyline` · 노드별 곡선 — [확인] 재구성과 같은 규칙). -공통 제약(모든 갈래) — 종단기울기 상한 · 최소 곡선반지름 · 계류 이격: - · 탐색 비용 — 상한 넘는 지반 기울기 링크 · 계류 버퍼 안 링크에 벌점(막지 않음 — 길이 없으면 - 노선이 끊기므로). - · 폴리라인화 — 최소 곡선반지름으로 원호를 끼우고 못 끼우면 위반 표시. - · 결과 — `metrics.over_limit` 에 제약별 초과 구간 수. +공통 제약(모든 갈래 · **넘지 못함**) — 종단기울기 상한 · 최소 곡선반지름 · 계류 이격: + · 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌. 곡선 맞춤 뒤 측점(20 m) 사이 + 지반이 상한을 넘으면 탐색 상한을 조금씩 낮춰 다시 찾음(`GRADE_CAP_STEPS`). + · 최소 곡선반지름 — 한 번에 이웃 방향(약 22.5°)으로만 꺾고, 꺾은 뒤 원호 두 개의 접선 길이만큼 + 곧게 가야 다시 꺾음 → 노드마다 최소 반지름 원호가 들어감. + · 계류 이격 — 버퍼 안에서는 계류 쪽 · 반대쪽 ±45° 로만 움직임(건너기 · 빠져나가기). + · 그래도 길이 없을 때만(지형상 불가능) 벌점 탐색으로 가장 적게 넘긴 길을 내고, 넘긴 곳을 + `metrics.violations`(측점 · 값 · 사유)에 적음. `over_limit` 수 = 그 목록 수. ⚠ 사용자 확정 대기 — 아래 임시값과 호출부가 넘기는 종단기울기 상한 · 최소 곡선반지름은 지식DB (`resources/knowledge/technical_info/01_임도`) 후보일 뿐이다(PLAN 20장 기준값 후보 표). @@ -34,13 +37,15 @@ from pathlib import Path from typing import Any import numpy as np -import shapely from scipy import ndimage -from scipy.interpolate import RegularGridInterpolator from scipy.sparse import csr_matrix -from scipy.sparse.csgraph import dijkstra -from shapely.geometry import LineString, MultiLineString +from scipy.sparse.csgraph import connected_components, dijkstra +from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import ( + GRADE_SMOOTH_M, + route_metrics, + smoothed_ground, +) from common_util.common_util_route_polyline import build_planned_polyline # ── 사용자 확정 대기(지식DB 후보 · PLAN 20장) ───────────────────────────────────── @@ -53,7 +58,14 @@ STREAM_CLASSES = ("소하천", "지방하천", "국가하천") ROAD_WIDTH_M = 4.0 # 탐색 복도 반폭(m) — 예상노선에서 이보다 멀리는 가지 않음(법령 값 아님 · 알고리즘 값). CORRIDOR_M = 100.0 -PENDING = ("max_grade_pct", "min_radius_m", "stream_offset_m", "road_width_m", "corridor_m") +PENDING = ( + "max_grade_pct", + "min_radius_m", + "stream_offset_m", + "road_width_m", + "corridor_m", + "grade_smooth_m", +) # ── 탐색 · 측정 값 ─────────────────────────────────────────────────────────────── MODES = ("follow", "earthwork", "gentle", "weighted") @@ -64,16 +76,15 @@ MODE_WEIGHTS = { "gentle": {"expected": 0.0, "earthwork": 0.0, "grade": 100.0}, } SEARCH_CELL_M = 4.0 # 탐색 격자 칸(m) — 비용면(2 m)을 솎아 씀 -SAMPLE_M = 2.0 # 결과 측정 간격(m) -STATION_M = 20.0 # 종단기울기 측정 간격(m) — 타당성평가 20 m 간격 · 측점 체계 LENGTH_COST = 0.05 # 1 m 당 거리 비용 — 목표가 평평한 곳에서 헤매지 않게 -# 지반 기울기가 상한을 넘는 링크 1 m 당 벌점 = 이 값 × (넘은 기울기 / 상한). 세게 두면(20) -# 제약만 남고 비중이 안 먹음(랩탑_보조 실측: 비중 100/0/0 · 80/10/10 · 50/25/25 결과 같음). -# 2 에서 비중을 올릴수록 이격 · 토공이 한 방향으로 바뀜(이격 4 → 16 m · 토공 4.9 → 4.0천 m³). -GRADE_OVER_COST = 2.0 -STREAM_COST = 3.0 # 계류 버퍼 안 링크 1 m 당 벌점 — 건너기는 되고 나란히 달리기는 피함 -TURN_COST = 1.0 # 꺾을 때 벌점 = 최소 곡선반지름 원호 길이(R·θ) × 이 값 -MAX_TURN_STEPS = 2 # 한 걸음에 꺾는 방향 칸 수 한도(16 방향 중 ±2 = 약 45°) +TURN_COST = 1.0 # 꺾을 때 벌점 = 최소 곡선반지름 원호 길이(R·θ) × 이 값 — 곧게 달리기 선호 +# 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음 +GRADE_CAP_STEPS = (1.0, 0.93, 0.85) +# 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당) — 넘긴 곳을 가장 적게 +SOFT_GRADE_COST = 500.0 # × (넘은 기울기 / 상한) — 50 · 500 · 5000 에서 넘는 곳 수 같음(랩탑_보조) +SOFT_STREAM_COST = 20.0 # 계류 버퍼 안 나란히 달리기 +BOUND_BISECT = 7 # 지형상 불가능할 때 막는 기울기를 찾는 이분 탐색 횟수(구간 1/128 까지) +STREAM_CROSS_COS = math.cos(math.radians(45.0)) # 버퍼 안 이동 방향 한도(계류 쪽 · 반대쪽 ±45°) # 16 방향(행, 열) — +x 에서 반시계. 나이트 걸음(1, 2) 등을 넣어 22.5° 안팎 방향을 곧게 달림. DIRECTIONS = ( (0, 1), @@ -93,8 +104,6 @@ DIRECTIONS = ( (-1, 1), (-1, 2), ) -# 계류 버퍼 안 한 구간이 건너기로 인정되는 길이 — 45° 이상으로 가로지를 때의 버퍼 안 길이. -STREAM_CROSS_MAX_M = 2.0 * STREAM_OFFSET_M * math.sqrt(2.0) @dataclass @@ -186,6 +195,10 @@ def stream_lines(features: list[dict[str, Any]]) -> list[list[tuple[float, float return lines +class NoRoute(ValueError): + """제약을 지키며 시점과 종점을 잇는 길이 없음.""" + + def generate_initial_route( expected: list[tuple[float, float]], terrain: Terrain, @@ -196,30 +209,70 @@ def generate_initial_route( max_grade: float, min_radius_m: float, ) -> dict[str, Any]: - """갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14).""" + """갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14). + + 공통 제약을 넘지 않는 길을 먼저 찾고(탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 곡선 맞춤 뒤 + 지반 차까지 없앰), 그런 길이 없을 때만 벌점 탐색으로 가장 적게 넘긴 길을 낸다. + """ if mode not in MODES: raise ValueError(f"모르는 갈래: {mode}") if len(expected) < 2: raise ValueError("예상노선 정점이 2개 미만입니다.") used = resolve_weights(mode, weights) - points = ( - list(expected) - if mode == "follow" - else _search_route(expected, terrain, streams, used, max_grade, min_radius_m) - ) - outline = build_planned_polyline(points, min_radius_m=min_radius_m, simplify=True) - metrics = route_metrics( - outline.vertices, - expected, - terrain, - streams, - max_grade=max_grade, - radius_runs=sum(1 for curve in outline.curves if curve.violations), - ) + grid = _SearchGrid(expected, terrain, streams) + + def attempt(hard_cap: float, bound: float | None) -> tuple[Any, dict[str, Any]]: + nodes = _search_route( + grid, + expected, + used, + limit=max_grade, + hard_cap=hard_cap, + strict_stream=bound is None, + min_radius_m=min_radius_m, + ) + # 노드마다 곡선(묶지 않음) — [확인] 재구성(`/route/replan`)과 같은 규칙 → 적용 뒤도 같은 선 + outline = build_planned_polyline( + nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes) + ) + metrics = route_metrics( + outline.vertices, + expected, + terrain, + streams, + outline.curves, + max_grade=max_grade, + min_radius_m=min_radius_m, + stream_offset_m=STREAM_OFFSET_M, + road_width_m=ROAD_WIDTH_M, + bound_pct=None if bound is None else bound * 100.0, + ) + return outline, metrics + + best: tuple[Any, dict[str, Any]] | None = None + for step in GRADE_CAP_STEPS: + try: + tried = attempt(max_grade * step, None) + except NoRoute: + break + if best is None or tried[1]["over_limit"]["total"] < best[1]["over_limit"]["total"]: + best = tried + if best[1]["over_limit"]["total"] == 0: + break + bound = None + if best is None: + # 지형상 불가능 — 반지름 · 계류 규칙을 지키는 어떤 길도 넘어야 하는 가장 작은 기울기를 + # 구해(`_terrain_bound`) 그 위는 막고, 상한과 그 사이는 벌점으로 가장 적게 넘긴다. + bound = _terrain_bound(grid, expected, used, max_grade, min_radius_m) + best = attempt(bound + 1e-9, bound) + outline, metrics = best return { "mode": mode, "logic": mode, "weights": used, + "relaxed": bound is not None, + # 지형상 불가능할 때만 — 복도 안 어떤 길도 넘어야 하는 기울기(%) · 그 위는 막았음 + "terrain_bound_pct": None if bound is None else round(bound * 100.0, 2), "planned": [[round(x, 4), round(y, 4)] for x, y in outline.vertices], "nodes": [node.as_dict() for node in outline.nodes], "curves": [curve.as_dict() for curve in outline.curves], @@ -230,6 +283,7 @@ def generate_initial_route( "stream_offset_m": STREAM_OFFSET_M, "road_width_m": ROAD_WIDTH_M, "corridor_m": CORRIDOR_M, + "grade_smooth_m": GRADE_SMOOTH_M, "pending": list(PENDING), }, } @@ -261,68 +315,200 @@ def _distance_grid( return ndimage.distance_transform_edt(~marked) * cell -def _search_route( +class _SearchGrid: + """탐색 격자 — 갈래 · 탐색 상한이 바뀌어도 같은 것(복도 · 거리 · 기울기)을 한 번만 만든다.""" + + def __init__( + self, + expected: list[tuple[float, float]], + terrain: Terrain, + streams: list[list[tuple[float, float]]], + ) -> None: + step = max(1, int(round(SEARCH_CELL_M / _cell_size(terrain.x)))) + self.xs, self.ys = terrain.x[::step], terrain.y[::step] + # 기울기 · 옆경사는 비교값과 같은 고른 지반에서 잰다(`smoothed_ground`) + self.z = smoothed_ground(terrain)[::step, ::step] + self.cell = _cell_size(self.xs) + self.d_expected = _distance_grid([expected], self.xs, self.ys, self.cell) + self.d_stream = _distance_grid(streams, self.xs, self.ys, self.cell) + valid = np.asarray(terrain.valid[::step, ::step], dtype=bool) + self.corridor = (self.d_expected <= CORRIDOR_M) & valid + self.grad_y, self.grad_x = np.gradient(self.z, self.cell) + finite = np.where(np.isfinite(self.d_stream), self.d_stream, 0.0) + self.away_y, self.away_x = np.gradient(finite, self.cell) # 계류에서 멀어지는 쪽 + self.index = np.full(self.z.shape, -1, dtype=np.int64) + self.cells = int(self.corridor.sum()) + self.index[self.corridor] = np.arange(self.cells) + rows, cols = np.nonzero(self.corridor) + self.centres = np.column_stack([self.xs[cols], self.ys[rows]]) + + def nearest(self, point: tuple[float, float]) -> int: + dx = self.centres[:, 0] - point[0] + return int(np.argmin(np.hypot(dx, self.centres[:, 1] - point[1]))) + + def _pairs(self, dr: int, dc: int) -> tuple: + """한 방향으로 이웃한 칸 짝(앞 · 뒤 조각, 앞 · 뒤 번호, 둘 다 복도 안).""" + rows, cols = self.z.shape + first = (slice(max(0, -dr), rows - max(0, dr)), slice(max(0, -dc), cols - max(0, dc))) + second = (slice(max(0, dr), rows - max(0, -dr)), slice(max(0, dc), cols - max(0, -dc))) + a, b = self.index[first], self.index[second] + return first, second, a, b, (a >= 0) & (b >= 0) + + def grades(self, dr: int, dc: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """복도 안 한 방향 링크의 (앞 번호, 뒤 번호, 지반 기울기).""" + first, second, a, b, ok = self._pairs(dr, dc) + grade = np.abs(self.z[second] - self.z[first]) / (self.cell * math.hypot(dr, dc)) + return a[ok], b[ok], grade[ok] + + def links( + self, dr: int, dc: int, *, limit: float, hard_cap: float, strict_stream: bool + ) -> tuple: + """한 방향 링크 · 세 목표 · 벌점(1 m 당). `hard_cap` 넘는 기울기 링크는 뺀다. + + `limit`(상한)과 `hard_cap` 사이는 벌점 — 지형상 불가능할 때만 그 사이가 생긴다. + `strict_stream` 이면 계류 버퍼 안 나란히 달리기도 뺀다(아니면 벌점). + """ + first, second, a, b, ok = self._pairs(dr, dc) + norm = math.hypot(dr, dc) + grade = np.abs(self.z[second] - self.z[first]) / (self.cell * norm) + # 계류 버퍼 안에서는 계류 쪽 · 반대쪽 ±45° 로만(건너기 · 빠져나가기) + in_buffer = (self.d_stream[first] < STREAM_OFFSET_M) | ( + self.d_stream[second] < STREAM_OFFSET_M + ) + ax = self.away_x[first] + self.away_x[second] + ay = self.away_y[first] + self.away_y[second] + size = np.hypot(ax, ay) + across = np.abs(ax * dc + ay * dr) >= STREAM_CROSS_COS * norm * size + along = in_buffer & ~across & (size > 1e-6) + ok &= grade <= hard_cap + if strict_stream: + ok &= ~along + grade, along = grade[ok], along[ok] + over = np.maximum(0.0, grade - limit) + # 옆경사 = 진행 방향에 수직인 지반 기울기(두 칸 평균) + cross = np.abs( + (self.grad_x[first][ok] + self.grad_x[second][ok]) * -dr + + (self.grad_y[first][ok] + self.grad_y[second][ok]) * dc + ) / (2.0 * norm) + # 토공 가늠(1 m 당 m³) — 옆경사 위 반절 · 반성 단면 W²·s/4 + 상한 넘는 기울기가 측점 + # 반 간격(10 m) 동안 쌓는 높이 차 단면(지형상 불가능할 때만 0 이 아님) + earth = ROAD_WIDTH_M**2 * cross / 4.0 + ROAD_WIDTH_M * over * 10.0 + offset = 0.5 * (self.d_expected[first][ok] + self.d_expected[second][ok]) + penalty = SOFT_GRADE_COST * over / limit + SOFT_STREAM_COST * along + terms = np.stack([offset, earth, (grade / limit) ** 2]) + return a[ok], b[ok], self.cell * norm, terms, penalty + + +def _minimax_grade(grid: _SearchGrid, expected: list[tuple[float, float]]) -> float: + """복도 안에서 시점과 종점을 잇는 데 **어떤 길도 넘어야 하는** 가장 작은 기울기 상한(비율). + + 꺾기 규칙 없이 칸끼리만 이은 그래프에서 이분 탐색 — 그러니 실제 필요한 값의 아래 한계다. + """ + pieces = [grid.grades(dr, dc) for dr, dc in DIRECTIONS] + heads = np.concatenate([p[0] for p in pieces]) + tails = np.concatenate([p[1] for p in pieces]) + grades = np.concatenate([p[2] for p in pieces]) + start, end = grid.nearest(expected[0]), grid.nearest(expected[-1]) + + def joined(cap: float) -> bool: + keep = grades <= cap + graph = csr_matrix( + (np.ones(int(keep.sum())), (heads[keep], tails[keep])), + shape=(grid.cells, grid.cells), + ) + _, labels = connected_components(graph, directed=False) + return bool(labels[start] == labels[end]) + + low, high = 0.0, float(grades.max()) if grades.size else 0.0 + if not joined(high): + raise NoRoute("복도 안에서 시점과 종점이 이어지지 않습니다.") + for _ in range(30): + middle = 0.5 * (low + high) + low, high = (low, middle) if joined(middle) else (middle, high) + return high + + +def _terrain_bound( + grid: _SearchGrid, expected: list[tuple[float, float]], - terrain: Terrain, - streams: list[list[tuple[float, float]]], weights: dict[str, float], max_grade: float, min_radius_m: float, -) -> list[tuple[float, float]]: - """복도 안 격자에서 진행 방향을 기억하는 Dijkstra — 시점 · 종점은 예상노선 양 끝 그대로. +) -> float: + """반지름 규칙까지 지키며 시점 · 종점을 잇는 데 **어떤 길도 넘어야 하는** 기울기(비율). - 상태 = (칸, 들어온 방향 16 가지). 한 걸음에 22.5° · 45° 까지만 꺾고, 꺾을 때마다 최소 - 곡선반지름 원호 길이만큼 벌점을 매겨 곧게 달리게 한다. 16 방향(체스 나이트 걸음 포함)이라 - 8 방향 격자가 급한 옆경사에서 등고선을 못 따라 계단처럼 꺾이던 것을 피한다. + 아래 한계 = 꺾기 규칙 없는 최소최대(`_minimax_grade`). 이어지는 값을 1.5 배씩 찾은 뒤 + 그 사이를 탐색 그래프 그대로 이분 탐색한다(계류는 벌점 — 기울기만 따짐). """ - step = max(1, int(round(SEARCH_CELL_M / _cell_size(terrain.x)))) - xs, ys = terrain.x[::step], terrain.y[::step] - z = np.asarray(terrain.z[::step, ::step], dtype=np.float64) - cell = _cell_size(xs) - d_expected = _distance_grid([expected], xs, ys, cell) - d_stream = _distance_grid(streams, xs, ys, cell) - corridor = (d_expected <= CORRIDOR_M) & np.asarray(terrain.valid[::step, ::step], dtype=bool) - grad_y, grad_x = np.gradient(z, cell) - rows, cols = z.shape - index = np.full((rows, cols), -1, dtype=np.int64) - cells = int(corridor.sum()) - index[corridor] = np.arange(cells) - # 방향마다 링크(칸 → 칸)와 세 목표 · 벌점(1 m 당) - links: list[tuple[np.ndarray, np.ndarray, float, np.ndarray, np.ndarray]] = [] - for dr, dc in DIRECTIONS: - first = (slice(max(0, -dr), rows - max(0, dr)), slice(max(0, -dc), cols - max(0, dc))) - second = (slice(max(0, dr), rows - max(0, -dr)), slice(max(0, dc), cols - max(0, -dc))) - a, b = index[first], index[second] - ok = (a >= 0) & (b >= 0) - norm = math.hypot(dr, dc) - grade = np.abs(z[second][ok] - z[first][ok]) / (cell * norm) - over = np.maximum(0.0, grade - max_grade) - # 옆경사 = 진행 방향에 수직인 지반 기울기(두 칸 평균) - cross = np.abs( - (grad_x[first][ok] + grad_x[second][ok]) * -dr - + (grad_y[first][ok] + grad_y[second][ok]) * dc - ) / (2.0 * norm) - # 토공 가늠(1 m 당 m³) — 옆경사 위 반절 · 반성 단면 W²·s/4 + 상한 넘는 기울기가 측점 - # 반 간격 동안 쌓는 높이 차 단면 - earth = ROAD_WIDTH_M**2 * cross / 4.0 + ROAD_WIDTH_M * over * STATION_M / 2.0 - offset = 0.5 * (d_expected[first][ok] + d_expected[second][ok]) - in_buffer = 0.5 * ( - (d_stream[first][ok] < STREAM_OFFSET_M).astype(float) - + (d_stream[second][ok] < STREAM_OFFSET_M) - ) - penalty = GRADE_OVER_COST * over / max_grade + STREAM_COST * in_buffer - links.append( - ( - a[ok], - b[ok], - cell * norm, - np.stack([offset, earth, (grade / max_grade) ** 2]), - penalty, + def joined(cap: float) -> bool: + try: + _search_route( + grid, + expected, + weights, + limit=max_grade, + hard_cap=cap, + strict_stream=False, + min_radius_m=min_radius_m, ) - ) + except NoRoute: + return False + return True + + ceiling = max(float(grid.grades(dr, dc)[2].max(initial=0.0)) for dr, dc in DIRECTIONS) + low = max(_minimax_grade(grid, expected), max_grade) + high = low + while not joined(high): + if high >= ceiling: + raise NoRoute("복도 안에서 반지름 규칙을 지키며 시점과 종점을 잇는 길이 없습니다.") + low, high = high, min(high * 1.5, ceiling) + for _ in range(BOUND_BISECT): + if high - low <= 1e-4: + break + middle = 0.5 * (low + high) + low, high = (low, middle) if joined(middle) else (middle, high) + return high + + +def _turn_steps(min_radius_m: float, cell: float) -> int: + """꺾은 뒤 곧게 가야 할 칸 수 — 앞뒤 원호 접선이 한 직선에 들어가게. + + 원호를 끼우는 쪽(`build_planned_polyline`)은 직선의 절반까지만 접선으로 쓰므로 직선은 + 2 · R · tan(θ/2) 이상이어야 한다. θ = 이웃 방향 사이 가장 큰 각(나이트 걸음 26.57°). + 걸음은 칸 길이 단위로 센다 — 곧은 · 대각 걸음 1, 나이트 걸음 2. + """ + widest = math.atan2(1.0, 2.0) + return max(1, math.ceil(2.0 * min_radius_m * math.tan(widest / 2.0) / cell)) + + +def _search_route( + grid: _SearchGrid, + expected: list[tuple[float, float]], + weights: dict[str, float], + *, + limit: float, + hard_cap: float, + strict_stream: bool, + min_radius_m: float, +) -> list[tuple[float, float]]: + """노드(꺾임점) 목록 — 첫 · 끝은 예상노선 양 끝 그대로. + + 상태 = (칸, 방향, 층). 층 = 꺾은 뒤 곧게 온 칸 수 + 1(K + 1 에서 멈춤) · 층 0 = 시점. + 이웃 방향으로 꺾기는 K 칸 곧게 온 뒤에만(층 K + 1). 시점은 한 칸 더(K + 1 칸) — 기점을 + 격자 칸 가운데에서 예상노선 끝점으로 옮기며 첫 직선이 짧아지는 몫. 종점은 K 칸 곧게 + 들어온 상태를 먼저, 없으면 아무 상태(마지막 곡선 자리가 모자라면 목록에 뜸). + """ + count = len(DIRECTIONS) + steps = _turn_steps(min_radius_m, grid.cell) + full = steps + 1 # 꺾을 수 있는 층 + layers = full + 1 + links = [ + grid.links(dr, dc, limit=limit, hard_cap=hard_cap, strict_stream=strict_stream) + for dr, dc in DIRECTIONS + ] if not any(link[0].size for link in links): - raise ValueError("예상노선 둘레에 탐색할 지형이 없습니다.") + raise NoRoute("예상노선 둘레에 제약을 지키며 지날 수 있는 링크가 없습니다.") # 세 목표 정규화 — 복도 안 링크 평균이 1 이 되게(길이 가중). 평균이 0 이면 그 목표는 0. lengths = np.concatenate([np.full(link[0].size, link[2]) for link in links]) @@ -332,160 +518,53 @@ def _search_route( share = np.array([weights[key] for key in order]) / max(sum(weights.values()), 1e-9) factor = np.divide(share, scale, out=np.zeros_like(share), where=scale > 1e-12) - count = len(DIRECTIONS) + def state(cell: np.ndarray | int, direction: int, walked: int) -> np.ndarray | int: + return (cell * count + direction) * layers + walked + angles = [math.atan2(dr, dc) for dr, dc in DIRECTIONS] heads, tails, costs = [], [], [] - for out, (a, b, length, term, penalty) in enumerate(links): - move = length * (LENGTH_COST + factor @ term + penalty) - for turn in range(-MAX_TURN_STEPS, MAX_TURN_STEPS + 1): - came = (out + turn) % count + for out, (a, b, length, terms, penalty) in enumerate(links): + move = length * (LENGTH_COST + factor @ terms + penalty) + units = max(1, int(length / grid.cell + 1e-9)) # 곧은 · 대각 1, 나이트 2 + for walked in range(layers): # 곧게 + heads.append(state(a, out, walked)) + tails.append(state(b, out, min(walked + units, full))) + costs.append(move) + for came in ((out - 1) % count, (out + 1) % count): # 이웃 방향에서 꺾어 들어옴 bend = abs((angles[out] - angles[came] + math.pi) % (2.0 * math.pi) - math.pi) - heads.append(a * count + came) - tails.append(b * count + out) + heads.append(state(a, came, full)) + tails.append(state(b, out, min(units + 1, full))) costs.append(move + TURN_COST * min_radius_m * bend) + size = grid.cells * count * layers graph = csr_matrix( (np.concatenate(costs), (np.concatenate(heads), np.concatenate(tails))), - shape=(cells * count, cells * count), + shape=(size, size), ) - cell_rows, cell_cols = np.nonzero(corridor) - centres = np.column_stack([xs[cell_cols], ys[cell_rows]]) - - def nearest(point: tuple[float, float]) -> int: - return int(np.argmin(np.hypot(centres[:, 0] - point[0], centres[:, 1] - point[1]))) - - start, end = nearest(expected[0]), nearest(expected[-1]) + start, end = grid.nearest(expected[0]), grid.nearest(expected[-1]) distance, predecessors, _ = dijkstra( graph, - indices=[start * count + d for d in range(count)], + indices=[state(start, d, 0) for d in range(count)], return_predecessors=True, min_only=True, ) - state = end * count + int(np.argmin(distance[end * count : (end + 1) * count])) - if not np.isfinite(distance[state]): - raise ValueError("복도 안에서 시점과 종점을 잇는 길을 찾지 못했습니다.") - path = [state] - while predecessors[path[-1]] >= 0: - path.append(int(predecessors[path[-1]])) - points = [ - (float(centres[s // count, 0]), float(centres[s // count, 1])) for s in reversed(path) - ] - # 양 끝은 격자 칸 가운데가 아니라 예상노선 끝점 그대로 — 기점 · 종점은 옮기지 않는다. - points[0], points[-1] = tuple(expected[0]), tuple(expected[-1]) - return points - - -# ── 비교값 ─────────────────────────────────────────────────────────────────────── - - -def _resample(vertices: list[tuple[float, float]], step: float) -> tuple[np.ndarray, np.ndarray]: - """폴리라인을 `step` 간격 점으로 — (점 [n,2], 누가거리 [n]).""" - xy = np.asarray(vertices, dtype=np.float64) - seg = np.hypot(*np.diff(xy, axis=0).T) - chain = np.concatenate([[0.0], np.cumsum(seg)]) - total = float(chain[-1]) - at = np.append(np.arange(0.0, total, step), total) if total > 0 else np.array([0.0]) - return np.column_stack([np.interp(at, chain, xy[:, 0]), np.interp(at, chain, xy[:, 1])]), at - - -def _runs(flags: np.ndarray) -> list[tuple[int, int]]: - """참이 이어진 구간들의 (첫, 끝) 번호.""" - runs: list[tuple[int, int]] = [] - start = None - for i, flag in enumerate(flags): - if flag and start is None: - start = i - elif not flag and start is not None: - runs.append((start, i - 1)) - start = None - if start is not None: - runs.append((start, len(flags) - 1)) - return runs - - -def grade_line(ground: np.ndarray, step: float, max_grade: float) -> np.ndarray: - """상한 안의 계획고 가늠 — 앞으로 · 뒤로 기울기를 누른 두 선의 평균(둘 다 상한 안 → 평균도).""" - rise = max_grade * step - forward, backward = ground.copy(), ground.copy() - for i in range(1, len(ground)): - forward[i] = min(max(ground[i], forward[i - 1] - rise), forward[i - 1] + rise) - for i in range(len(ground) - 2, -1, -1): - backward[i] = min(max(ground[i], backward[i + 1] - rise), backward[i + 1] + rise) - return 0.5 * (forward + backward) - - -def route_metrics( - vertices: list[tuple[float, float]], - expected: list[tuple[float, float]], - terrain: Terrain, - streams: list[list[tuple[float, float]]], - *, - max_grade: float, - radius_runs: int, -) -> dict[str, Any]: - """연장 · 최대/평균 종단기울기(지반) · 절성토량(가늠) · 기준 초과 구간 수 · 예상노선 이격.""" - sampler = RegularGridInterpolator( - (terrain.y, terrain.x), terrain.z, bounds_error=False, fill_value=None - ) - points, chain = _resample(vertices, SAMPLE_M) - length = float(chain[-1]) - ground = sampler(points[:, ::-1]) - - # 종단기울기 — 측점(20 m) 사이 지반 기울기 - stations = np.append(np.arange(0.0, length, STATION_M), length) - station_z = np.interp(stations, chain, ground) - spans = np.diff(stations) - keep = spans > 0.5 - grades = np.abs(np.diff(station_z))[keep] / spans[keep] - max_grade_run = float(grades.max()) if grades.size else 0.0 - avg_grade = float((grades * spans[keep]).sum() / spans[keep].sum()) if grades.size else 0.0 - - # 절 · 성토(가늠) — 상한 안 계획고와 옆경사 단면을 폭 W 로 적분(비탈면 제외) - grad_y, grad_x = np.gradient(terrain.z, _cell_size(terrain.y), _cell_size(terrain.x)) - gx = RegularGridInterpolator( - (terrain.y, terrain.x), grad_x, bounds_error=False, fill_value=None - ) - gy = RegularGridInterpolator( - (terrain.y, terrain.x), grad_y, bounds_error=False, fill_value=None - ) - tangent = np.gradient(points, axis=0) - tangent /= np.maximum(np.hypot(tangent[:, 0], tangent[:, 1]), 1e-9)[:, None] - lookup = points[:, ::-1] - cross = np.abs(gx(lookup) * -tangent[:, 1] + gy(lookup) * tangent[:, 0]) - height = grade_line(ground, SAMPLE_M, max_grade) - ground - across = np.linspace(-ROAD_WIDTH_M / 2.0, ROAD_WIDTH_M / 2.0, 9) - depth = cross[:, None] * across[None, :] - height[:, None] # +: 지반이 노면 위 = 절토 - weight = np.gradient(chain) if len(chain) > 1 else np.zeros_like(chain) - cut = float((np.maximum(depth, 0.0).mean(axis=1) * ROAD_WIDTH_M * weight).sum()) - fill = float((np.maximum(-depth, 0.0).mean(axis=1) * ROAD_WIDTH_M * weight).sum()) - - # 예상노선 이격 - route_points = shapely.points(points) - offsets = shapely.distance(route_points, LineString(expected)) - - # 계류 이격 — 버퍼 안 구간 중 봐줄 길이를 넘는 것. 봐줌 = 건너기(계류에 닿음) 한 번의 - # 버퍼 안 길이 + 기점 · 종점이 버퍼 안이면 45° 로 빠져나가는 길이(끝점은 옮길 수 없음) - stream_runs = 0 - if streams: - near = shapely.distance(route_points, MultiLineString(streams)) - for first, last in _runs(near < STREAM_OFFSET_M): - ends = int(first == 0) + int(last == len(near) - 1) - crosses = float(near[first : last + 1].min()) <= SAMPLE_M - allowance = ends * STREAM_OFFSET_M * math.sqrt(2.0) + crosses * STREAM_CROSS_MAX_M - if chain[last] - chain[first] > allowance: - stream_runs += 1 - grade_runs = len(_runs(grades > max_grade)) - return { - "length_m": round(length, 2), - "max_grade_pct": round(max_grade_run * 100.0, 2), - "avg_grade_pct": round(avg_grade * 100.0, 2), - "cut_m3": round(cut, 1), - "fill_m3": round(fill, 1), - "over_limit": { - "grade": grade_runs, - "radius": int(radius_runs), - "stream": stream_runs, - "total": grade_runs + int(radius_runs) + stream_runs, - }, - "offset_avg_m": round(float(offsets.mean()), 2), - "offset_max_m": round(float(offsets.max()), 2), - } + straight = [state(end, d, full) for d in range(count)] + final = min(straight, key=lambda s: distance[s]) + if not np.isfinite(distance[final]): + final = min( + (state(end, d, w) for d in range(count) for w in range(layers)), + key=lambda s: distance[s], + ) + if not np.isfinite(distance[final]): + raise NoRoute("복도 안에서 제약을 지키며 시점과 종점을 잇는 길이 없습니다.") + chain = [int(final)] + while predecessors[chain[-1]] >= 0: + chain.append(int(predecessors[chain[-1]])) + chain.reverse() + # 꺾임점 = 방향이 바뀌는 칸. 첫 · 끝은 예상노선 끝점 그대로(기점 · 종점은 옮기지 않음). + nodes = [tuple(expected[0])] + for previous, current in zip(chain[1:], chain[2:]): + if (previous // layers) % count != (current // layers) % count: + cell = previous // layers // count + nodes.append((float(grid.centres[cell, 0]), float(grid.centres[cell, 1]))) + nodes.append(tuple(expected[-1])) + return nodes diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Metrics.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Metrics.py new file mode 100644 index 00000000..518774a9 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Metrics.py @@ -0,0 +1,228 @@ +"""초기 계획노선 갈래의 비교값 · 제약 못 지킨 구간 목록(PLAN 20장). + +`B05_Profile_Engine_RouteInitial` 이 만든 폴리라인 하나를 받아 연장 · 종단기울기 · 절성토량 · +예상노선 이격을 재고, 공통 제약(종단기울기 상한 · 최소 곡선반지름 · 계류 이격)을 못 지킨 +구간을 측점 · 값 · 사유로 모은다. `over_limit` 수는 이 목록 수와 같다. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any + +import numpy as np +import shapely +from scipy.interpolate import RegularGridInterpolator +from shapely.geometry import LineString, MultiLineString, Point + +if TYPE_CHECKING: + from B05_Profile.B05_Profile_Engine_RouteInitial import Terrain + from common_util.common_util_route_polyline import RouteCurve + +SAMPLE_M = 2.0 # 측정 간격(m) +# 종단기울기를 잴 지반 = DEM 을 이 폭(가우스 σ, m)으로 고른 것 — 도상(1/5,000) 측정 가늠. 지반의 +# 잔굴곡(수 m 턱 · 절개면)은 노면 계획고가 흡수하므로 노선 기울기에서 뺀다(사용자 확정 대기). +GRADE_SMOOTH_M = 5.0 +STATION_M = 20.0 # 종단기울기 측정 간격(m) — 측점 체계 · 타당성평가 20 m 간격 +GRADE_EPS = 1e-6 # 상한과 같은 기울기는 지킨 것으로 봄 + +REASON_TERRAIN = "지형상 불가능 — 복도 안 어떤 길도 {bound:.1f}% 이상 기울기를 지나야 이어짐" +REASON_GRADE_FIT = "곡선 맞춤 뒤 지반 차 — 측점 사이 지반이 상한을 넘음" +REASON_RADIUS = "곡선 둘 자리 모자람 — 앞뒤 직선이 짧아 최소 반지름 원호가 안 들어감" +REASON_STREAM = "계류 버퍼 안 — 건너기 · 기점 · 종점 봐줌을 넘음" +REASON_STREAM_TERRAIN = "지형상 불가능 — 기울기 · 반지름을 지키며 계류 버퍼를 비켜 잇는 길 없음" + + +def smoothed_ground(terrain: Terrain) -> np.ndarray: + """종단기울기를 잴 지반(`GRADE_SMOOTH_M` 로 고른 DEM) — 탐색과 비교값이 같은 것을 본다.""" + from scipy.ndimage import gaussian_filter + + spacing = 0.5 * (_spacing(terrain.x) + _spacing(terrain.y)) + return gaussian_filter(np.asarray(terrain.z, dtype=np.float64), GRADE_SMOOTH_M / spacing) + + +def station_label(chainage_m: float) -> str: + """측점 표기 — No.(20 m 번호)+나머지(m).""" + number = int(chainage_m // STATION_M) + return f"No.{number}+{chainage_m - number * STATION_M:.1f}" + + +def _resample(vertices: list[tuple[float, float]], step: float) -> tuple[np.ndarray, np.ndarray]: + """폴리라인을 `step` 간격 점으로 — (점 [n,2], 누가거리 [n]).""" + xy = np.asarray(vertices, dtype=np.float64) + seg = np.hypot(*np.diff(xy, axis=0).T) + chain = np.concatenate([[0.0], np.cumsum(seg)]) + total = float(chain[-1]) + at = np.append(np.arange(0.0, total, step), total) if total > 0 else np.array([0.0]) + return np.column_stack([np.interp(at, chain, xy[:, 0]), np.interp(at, chain, xy[:, 1])]), at + + +def _runs(flags: np.ndarray) -> list[tuple[int, int]]: + """참이 이어진 구간들의 (첫, 끝) 번호.""" + runs: list[tuple[int, int]] = [] + start = None + for i, flag in enumerate(flags): + if flag and start is None: + start = i + elif not flag and start is not None: + runs.append((start, i - 1)) + start = None + if start is not None: + runs.append((start, len(flags) - 1)) + return runs + + +def grade_line(ground: np.ndarray, step: float, max_grade: float) -> np.ndarray: + """상한 안의 계획고 가늠 — 앞으로 · 뒤로 기울기를 누른 두 선의 평균(둘 다 상한 안 → 평균도).""" + rise = max_grade * step + forward, backward = ground.copy(), ground.copy() + for i in range(1, len(ground)): + forward[i] = min(max(ground[i], forward[i - 1] - rise), forward[i - 1] + rise) + for i in range(len(ground) - 2, -1, -1): + backward[i] = min(max(ground[i], backward[i + 1] - rise), backward[i + 1] + rise) + return 0.5 * (forward + backward) + + +def _item(kind: str, start: float, end: float, value: float, limit: float, reason: str) -> dict: + return { + "kind": kind, + "from_m": round(start, 1), + "to_m": round(end, 1), + "station": station_label(start), + "value": round(value, 2), + "limit": round(limit, 2), + "reason": reason, + } + + +def route_metrics( + vertices: list[tuple[float, float]], + expected: list[tuple[float, float]], + terrain: Terrain, + streams: list[list[tuple[float, float]]], + curves: list[RouteCurve], + *, + max_grade: float, + min_radius_m: float, + stream_offset_m: float, + road_width_m: float, + bound_pct: float | None, +) -> dict[str, Any]: + """비교값과 제약 못 지킨 구간 목록. 종단기울기 = 측점(20 m) 사이 고른 지반 기울기. + + `bound_pct` 는 제약을 다 지키는 길이 없을 때만 — 복도 안 어떤 길도 넘어야 하는 기울기(%). + """ + grid = (terrain.y, terrain.x) + sampler = RegularGridInterpolator(grid, terrain.z, bounds_error=False, fill_value=None) + smooth = RegularGridInterpolator( + grid, smoothed_ground(terrain), bounds_error=False, fill_value=None + ) + points, chain = _resample(vertices, SAMPLE_M) + length = float(chain[-1]) + lookup = points[:, ::-1] + ground = sampler(lookup) + relaxed = bound_pct is not None + terrain_reason = REASON_TERRAIN.format(bound=bound_pct or 0.0) + violations: list[dict[str, Any]] = [] + + # 종단기울기 — 측점 사이 고른 지반 기울기 · 넘은 측점 구간을 이어 한 항목 + stations = np.append(np.arange(0.0, length, STATION_M), length) + station_z = np.interp(stations, chain, smooth(lookup)) + raw_z = np.interp(stations, chain, ground) + spans = np.diff(stations) + keep = spans > 0.5 + grades = np.abs(np.diff(station_z))[keep] / spans[keep] + raw_grades = np.abs(np.diff(raw_z))[keep] / spans[keep] + starts, ends = stations[:-1][keep], stations[1:][keep] + for first, last in _runs(grades > max_grade + GRADE_EPS): + violations.append( + _item( + "grade", + float(starts[first]), + float(ends[last]), + float(grades[first : last + 1].max()) * 100.0, + max_grade * 100.0, + terrain_reason if relaxed else REASON_GRADE_FIT, + ) + ) + + # 최소 곡선반지름 — 원호가 하한 아래로 줄어든 곡선 + line = LineString(vertices) + for curve in curves: + if curve.violations: + violations.append( + _item( + "radius", + line.project(Point(curve.start)), + line.project(Point(curve.end)), + curve.radius_m, + min_radius_m, + REASON_RADIUS, + ) + ) + + # 계류 이격 — 버퍼 안 구간 중 봐줄 길이를 넘는 것. 봐줌 = 건너기(계류에 닿음) 한 번의 + # 버퍼 안 길이(45° 로 가로지름) + 기점 · 종점이 버퍼 안이면 45° 로 빠져나가는 길이 + route_points = shapely.points(points) + if streams: + near = shapely.distance(route_points, MultiLineString(streams)) + for first, last in _runs(near < stream_offset_m): + ends_in = int(first == 0) + int(last == len(near) - 1) + crosses = float(near[first : last + 1].min()) <= SAMPLE_M + allowance = (ends_in + 2 * crosses) * stream_offset_m * math.sqrt(2.0) + if chain[last] - chain[first] > allowance: + violations.append( + _item( + "stream", + float(chain[first]), + float(chain[last]), + float(near[first : last + 1].min()), + stream_offset_m, + REASON_STREAM_TERRAIN if relaxed else REASON_STREAM, + ) + ) + + # 절 · 성토(가늠) — 상한 안 계획고와 옆경사 단면을 폭 W 로 적분(비탈면 제외) + grad_y, grad_x = np.gradient(terrain.z, _spacing(terrain.y), _spacing(terrain.x)) + gx = RegularGridInterpolator( + (terrain.y, terrain.x), grad_x, bounds_error=False, fill_value=None + ) + gy = RegularGridInterpolator( + (terrain.y, terrain.x), grad_y, bounds_error=False, fill_value=None + ) + tangent = np.gradient(points, axis=0) + tangent /= np.maximum(np.hypot(tangent[:, 0], tangent[:, 1]), 1e-9)[:, None] + cross = np.abs(gx(lookup) * -tangent[:, 1] + gy(lookup) * tangent[:, 0]) + height = grade_line(ground, SAMPLE_M, max_grade) - ground + across = np.linspace(-road_width_m / 2.0, road_width_m / 2.0, 9) + depth = cross[:, None] * across[None, :] - height[:, None] # +: 지반이 노면 위 = 절토 + weight = np.gradient(chain) if len(chain) > 1 else np.zeros_like(chain) + cut = float((np.maximum(depth, 0.0).mean(axis=1) * road_width_m * weight).sum()) + fill = float((np.maximum(-depth, 0.0).mean(axis=1) * road_width_m * weight).sum()) + + offsets = shapely.distance(route_points, LineString(expected)) + counts = { + kind: sum(1 for v in violations if v["kind"] == kind) + for kind in ("grade", "radius", "stream") + } + violations.sort(key=lambda item: item["from_m"]) + return { + "length_m": round(length, 2), + "max_grade_pct": round(float(grades.max()) * 100.0 if grades.size else 0.0, 2), + "avg_grade_pct": round( + float((grades * spans[keep]).sum() / spans[keep].sum()) * 100.0 if grades.size else 0.0, + 2, + ), + # 참고 — 고르지 않은 DEM 측점 사이 최대 기울기(잔굴곡 포함 · 판정에 안 씀) + "raw_max_grade_pct": round(float(raw_grades.max()) * 100.0 if raw_grades.size else 0.0, 2), + "cut_m3": round(cut, 1), + "fill_m3": round(fill, 1), + "over_limit": {**counts, "total": len(violations)}, + "offset_avg_m": round(float(offsets.mean()), 2), + "offset_max_m": round(float(offsets.max()), 2), + "violations": violations, + } + + +def _spacing(coords: np.ndarray) -> float: + return float((coords[-1] - coords[0]) / (len(coords) - 1)) if len(coords) > 1 else 1.0 diff --git a/resources/tester/test_route_initial.py b/resources/tester/test_route_initial.py index b75fec21..b3637478 100644 --- a/resources/tester/test_route_initial.py +++ b/resources/tester/test_route_initial.py @@ -1,6 +1,9 @@ """초기 계획노선 갈래(PLAN 20장) — `B05_Profile_Engine_RouteInitial` 합성 지형 시험. -./venv/Scripts/python.exe -m pytest resources/tester/test_route_initial.py -q + ./venv/Scripts/python.exe -m pytest resources/tester/test_route_initial.py -q + +공통 제약(종단기울기 상한 · 최소 곡선반지름 · 계류 이격)은 넘지 못함 — 지형상 불가능할 때만 +`metrics.violations` 에 측점 · 값 · 사유로 뜨고 `over_limit.total` 은 그 수와 같다. """ import numpy as np @@ -12,12 +15,12 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import ( STREAM_OFFSET_M, Terrain, generate_initial_route, - grade_line, read_initial_choice, resolve_weights, stream_lines, write_initial_choice, ) +from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import grade_line, station_label GRADE = 0.14 RADIUS = 12.0 @@ -34,14 +37,6 @@ def _line(y: float) -> list[tuple[float, float]]: return [(float(x), y) for x in np.arange(50.0, 752.0, 4.0)] -@pytest.fixture(scope="module") -def hill() -> Terrain: - """가운데 솟은 언덕(높이 40 m) — 곧은 예상노선이 꼭대기를 넘는다.""" - return _terrain( - lambda xx, yy: 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2) - ) - - def _run(terrain: Terrain, mode: str, streams=(), weights=None) -> dict: return generate_initial_route( _line(200.0), @@ -54,71 +49,98 @@ def _run(terrain: Terrain, mode: str, streams=(), weights=None) -> dict: ) -def test_follow_keeps_expected_route(hill: Terrain) -> None: - result = _run(hill, "follow") - assert result["metrics"]["offset_max_m"] < 1.0 - assert result["weights"] == {"expected": 100.0, "earthwork": 0.0, "grade": 0.0} - assert result["criteria"]["pending"] == list(PENDING) - - -@pytest.mark.parametrize("mode", ["earthwork", "gentle", "weighted"]) -def test_search_modes_beat_follow_on_hill(hill: Terrain, mode: str) -> None: - follow = _run(hill, "follow")["metrics"] - result = _run(hill, mode) +def _kept(result: dict) -> None: + """제약을 지켰거나, 못 지킨 곳이 목록에 있고 수가 같음.""" metrics = result["metrics"] - # 언덕을 돌아가 기울기 · 토공이 준다 - assert metrics["max_grade_pct"] < follow["max_grade_pct"] - assert metrics["cut_m3"] + metrics["fill_m3"] < follow["cut_m3"] + follow["fill_m3"] - assert metrics["over_limit"]["grade"] <= follow["over_limit"]["grade"] + assert metrics["over_limit"]["total"] == len(metrics["violations"]) + if not metrics["violations"]: + assert metrics["max_grade_pct"] <= GRADE * 100.0 + 1e-6 + assert result["relaxed"] is False + for curve in result["curves"]: + assert curve["radius_m"] >= RADIUS - 1e-6 or curve["violations"] + + +@pytest.fixture(scope="module") +def hill() -> Terrain: + """가운데 솟은 언덕(높이 40 m) — 곧은 예상노선이 꼭대기(측면 27 %)를 넘는다.""" + return _terrain( + lambda xx, yy: 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2) + ) + + +@pytest.mark.parametrize("mode", MODES) +def test_every_mode_keeps_grade_limit_on_hill(hill: Terrain, mode: str) -> None: + result = _run(hill, mode) + _kept(result) + metrics = result["metrics"] + assert metrics["over_limit"]["total"] == 0 + assert metrics["max_grade_pct"] <= GRADE * 100.0 + 1e-6 # 기점 · 종점은 옮기지 않고 복도(100 m) 밖으로 안 나감 assert result["planned"][0] == [50.0, 200.0] assert result["planned"][-1] == [750.0, 200.0] assert metrics["offset_max_m"] <= result["criteria"]["corridor_m"] + 4.0 - assert result["nodes"] and result["curves"] is not None + + +def test_follow_stays_on_expected_where_allowed() -> None: + """고른 경사(2 %) — 추종은 예상노선을 그대로(격자 칸 반 안쪽).""" + ramp = _terrain(lambda xx, yy: 0.02 * xx) + result = _run(ramp, "follow") + _kept(result) + assert result["metrics"]["offset_max_m"] <= 3.0 def test_weighted_expected_share_pulls_route_back(hill: Terrain) -> None: near = _run(hill, "weighted", weights={"expected": 90, "earthwork": 5, "grade": 5}) far = _run(hill, "weighted", weights={"expected": 10, "earthwork": 45, "grade": 45}) - assert near["metrics"]["offset_avg_m"] < far["metrics"]["offset_avg_m"] + assert near["metrics"]["offset_avg_m"] <= far["metrics"]["offset_avg_m"] -def test_stream_offset_is_common_constraint() -> None: - """평지 · 예상노선 20 m 옆을 나란히 흐르는 소하천 — 추종은 초과 1 · 탐색은 비켜 감.""" +def test_stream_offset_is_hard() -> None: + """평지 · 예상노선 20 m 옆을 나란히 흐르는 소하천 — 모든 갈래가 버퍼를 비켜 감.""" flat = _terrain(lambda xx, yy: np.zeros_like(xx)) stream = [[(0.0, 220.0), (800.0, 220.0)]] - follow = _run(flat, "follow", streams=stream)["metrics"] - moved = _run(flat, "earthwork", streams=stream)["metrics"] - assert follow["over_limit"]["stream"] == 1 - assert moved["over_limit"]["stream"] == 0 - assert moved["offset_avg_m"] > STREAM_OFFSET_M - 20.0 - 10.0 + for mode in MODES: + result = _run(flat, mode, streams=stream) + _kept(result) + assert result["metrics"]["over_limit"]["stream"] == 0 + assert result["metrics"]["offset_avg_m"] > STREAM_OFFSET_M - 20.0 - 30.0 def test_stream_crossing_is_allowed() -> None: flat = _terrain(lambda xx, yy: np.zeros_like(xx)) crossing = [[(400.0, 0.0), (400.0, 400.0)]] - assert _run(flat, "follow", streams=crossing)["metrics"]["over_limit"]["stream"] == 0 + result = _run(flat, "follow", streams=crossing) + _kept(result) + assert result["metrics"]["over_limit"]["total"] == 0 + + +def test_terrain_impossible_is_listed() -> None: + """복도 전체를 가로지르는 20 m 벽 — 어떤 길도 상한을 넘음 → 목록 · 사유 · 막은 기울기.""" + wall = _terrain(lambda xx, yy: np.where(xx > 400.0, 20.0, 0.0)) + result = _run(wall, "weighted") + _kept(result) + assert result["relaxed"] is True + assert result["terrain_bound_pct"] > GRADE * 100.0 + grades = [v for v in result["metrics"]["violations"] if v["kind"] == "grade"] + assert grades and all("지형상 불가능" in v["reason"] for v in grades) + assert all(v["station"].startswith("No.") for v in grades) def test_grade_line_stays_within_limit() -> None: ground = np.array([0.0, 0.0, 5.0, 10.0, 10.0, 2.0, 0.0]) line = grade_line(ground, 2.0, GRADE) assert np.all(np.abs(np.diff(line)) <= GRADE * 2.0 + 1e-9) + assert station_label(125.0) == "No.6+5.0" def test_weights_and_stream_filter() -> None: assert resolve_weights("weighted", None) == {"expected": 50.0, "earthwork": 25.0, "grade": 25.0} assert resolve_weights("gentle", {"expected": 99})["grade"] == 100.0 assert set(MODES) == {"follow", "earthwork", "gentle", "weighted"} + line = {"type": "LineString", "coordinates": [[0, 0], [1, 1]]} features = [ - { - "properties": {"구분": "세류"}, - "geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}, - }, - { - "properties": {"구분": "소하천"}, - "geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}, - }, + {"properties": {"구분": "세류"}, "geometry": line}, + {"properties": {"구분": "소하천"}, "geometry": line}, ] assert len(stream_lines(features)) == 1 @@ -128,6 +150,10 @@ def test_unknown_mode_rejected(hill: Terrain) -> None: _run(hill, "shortest") +def test_criteria_pending(hill: Terrain) -> None: + assert _run(hill, "follow")["criteria"]["pending"] == list(PENDING) + + def test_initial_choice_roundtrip(tmp_path) -> None: """[확인] 때 적은 현재 초기값을 다시 읽음 · 없거나 깨지면 추종.""" assert read_initial_choice(tmp_path)["logic"] == "follow"