fix(B05): 초기노선 공통 제약을 넘지 못하게 — 지형상 불가능한 곳만 목록(측점 · 값 · 사유)

- 종단기울기 상한 넘는 링크 · 계류 버퍼 안 나란히 달리기는 탐색에서 뺌(추종 갈래 포함)
- 최소 곡선반지름: 이웃 방향으로만 꺾고 접선 길이만큼 곧게 간 뒤 다시 꺾음 · 노드마다 원호
- 길이 없을 때만 막은 기울기(규칙 지키는 어떤 길도 넘어야 하는 값)를 이분 탐색해 그 위는 막고 넘긴 곳을 metrics.violations 에
- over_limit 수 = 목록 수 · 종단기울기는 5 m 로 고른 지반의 측점 20 m 기울기(탐색 · 비교값 같은 잣대)
- 비교값 · 목록은 새 파일 B05_Profile_Engine_RouteInitial_Metrics.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
This commit is contained in:
2026-09-29 10:35:44 +09:00
co-authored by Claude Opus 5.5
parent 927b7aa727
commit 258a3512a4
3 changed files with 615 additions and 282 deletions
+318 -239
View File
@@ -4,21 +4,24 @@
받은 노드로 기존 `POST /route/replan`([확인])을 부른다. 받은 노드로 기존 `POST /route/replan`([확인])을 부른다.
갈래(`mode`): 갈래(`mode`):
· follow — 지금 로직 그대로: 예상노선 점 묶음을 폴리라인화(`build_planned_polyline`). · follow — 예상노선 이격 최소(예상노선을 따르되 공통 제약은 지킴).
`B05_Profile_Router_Replan._ensure_planned_initial` 과 같은 입력 · 같은 결과. · earthwork — 절 · 성토 합 최소(DEM 옆경사로 가늠한 단면 토량).
· earthwork — 절 · 성토 합 최소(DEM 옆경사로 가늠한 단면 토량 + 상한 넘는 지반 기울기).
· gentle — 종단기울기 최소(지반 기울기 제곱 합). · gentle — 종단기울기 최소(지반 기울기 제곱 합).
· weighted — 예상노선 이격 · 토공 · 기울기 세 목표를 **정규화해 가중합**(합 100). · weighted — 예상노선 이격 · 토공 · 기울기 세 목표를 **정규화해 가중합**(합 100).
정규화 = 목표마다 복도 안 링크 평균값으로 나눔 — 단위가 다른 세 값을 1 m 당 정규화 = 목표마다 복도 안 링크 평균값으로 나눔 — 단위가 다른 세 값을 1 m 당
「보통 크기 1」로 맞춘 뒤 비중을 곱한다. 「보통 크기 1」로 맞춘 뒤 비중을 곱한다.
탐색 = 예상노선 둘레 복도(`CORRIDOR_M`) 안 `SEARCH_CELL_M` 격자의 8-연결 Dijkstra(scipy csgraph). 탐색 = 예상노선 둘레 복도(`CORRIDOR_M`) 안 `SEARCH_CELL_M` 격자 · 16 방향 Dijkstra(scipy csgraph).
탐색 격자 경로는 follow 와 같은 폴리라인화(꺾임점 뽑기 + 원호 끼우기)를 지난다. 상태 = (칸, 진행 방향, 꺾은 뒤 곧게 온 걸음 수). 탐색 경로의 꺾임점이 곧 노드이고, 노드마다
최소 곡선반지름 원호를 끼운다(`build_planned_polyline` · 노드별 곡선 — [확인] 재구성과 같은 규칙).
공통 제약(모든 갈래) — 종단기울기 상한 · 최소 곡선반지름 · 계류 이격: 공통 제약(모든 갈래 · **넘지 못함**) — 종단기울기 상한 · 최소 곡선반지름 · 계류 이격:
· 탐색 비용 — 상한 넘는 지반 기울기 링크 · 계류 버퍼 안 링크에 벌점(막지 않음 — 길이 없으면 · 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌. 곡선 맞춤 뒤 측점(20 m) 사이
노선이 끊기므로). 지반이 상한을 넘으면 탐색 상한을 조금씩 낮춰 다시 찾음(`GRADE_CAP_STEPS`).
· 폴리라인화 — 최소 곡선반지름으로 원호를 끼우고 못 끼우면 위반 표시. · 최소 곡선반지름 — 한 번에 이웃 방향(약 22.5°)으로만 꺾고, 꺾은 뒤 원호 두 개의 접선 길이만큼
· 결과 — `metrics.over_limit` 에 제약별 초과 구간 수. 곧게 가야 다시 꺾음 → 노드마다 최소 반지름 원호가 들어감.
· 계류 이격 — 버퍼 안에서는 계류 쪽 · 반대쪽 ±45° 로만 움직임(건너기 · 빠져나가기).
· 그래도 길이 없을 때만(지형상 불가능) 벌점 탐색으로 가장 적게 넘긴 길을 내고, 넘긴 곳을
`metrics.violations`(측점 · 값 · 사유)에 적음. `over_limit` 수 = 그 목록 수.
⚠ 사용자 확정 대기 — 아래 임시값과 호출부가 넘기는 종단기울기 상한 · 최소 곡선반지름은 지식DB ⚠ 사용자 확정 대기 — 아래 임시값과 호출부가 넘기는 종단기울기 상한 · 최소 곡선반지름은 지식DB
(`resources/knowledge/technical_info/01_임도`) 후보일 뿐이다(PLAN 20장 기준값 후보 표). (`resources/knowledge/technical_info/01_임도`) 후보일 뿐이다(PLAN 20장 기준값 후보 표).
@@ -34,13 +37,15 @@ from pathlib import Path
from typing import Any from typing import Any
import numpy as np import numpy as np
import shapely
from scipy import ndimage from scipy import ndimage
from scipy.interpolate import RegularGridInterpolator
from scipy.sparse import csr_matrix from scipy.sparse import csr_matrix
from scipy.sparse.csgraph import dijkstra from scipy.sparse.csgraph import connected_components, dijkstra
from shapely.geometry import LineString, MultiLineString
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 from common_util.common_util_route_polyline import build_planned_polyline
# ── 사용자 확정 대기(지식DB 후보 · PLAN 20장) ───────────────────────────────────── # ── 사용자 확정 대기(지식DB 후보 · PLAN 20장) ─────────────────────────────────────
@@ -53,7 +58,14 @@ STREAM_CLASSES = ("소하천", "지방하천", "국가하천")
ROAD_WIDTH_M = 4.0 ROAD_WIDTH_M = 4.0
# 탐색 복도 반폭(m) — 예상노선에서 이보다 멀리는 가지 않음(법령 값 아님 · 알고리즘 값). # 탐색 복도 반폭(m) — 예상노선에서 이보다 멀리는 가지 않음(법령 값 아님 · 알고리즘 값).
CORRIDOR_M = 100.0 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") MODES = ("follow", "earthwork", "gentle", "weighted")
@@ -64,16 +76,15 @@ MODE_WEIGHTS = {
"gentle": {"expected": 0.0, "earthwork": 0.0, "grade": 100.0}, "gentle": {"expected": 0.0, "earthwork": 0.0, "grade": 100.0},
} }
SEARCH_CELL_M = 4.0 # 탐색 격자 칸(m) — 비용면(2 m)을 솎아 씀 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 당 거리 비용 — 목표가 평평한 곳에서 헤매지 않게 LENGTH_COST = 0.05 # 1 m 당 거리 비용 — 목표가 평평한 곳에서 헤매지 않게
# 지반 기울기가 상한을 넘는 링크 1 m 당 벌점 = 이 값 × (넘은 기울기 / 상한). 세게 두면(20) TURN_COST = 1.0 # 꺾을 때 벌점 = 최소 곡선반지름 원호 길이(R·θ) × 이 값 — 곧게 달리기 선호
# 제약만 남고 비중이 안 먹음(랩탑_보조 실측: 비중 100/0/0 · 80/10/10 · 50/25/25 결과 같음). # 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음
# 2 에서 비중을 올릴수록 이격 · 토공이 한 방향으로 바뀜(이격 4 → 16 m · 토공 4.9 → 4.0천 m³). GRADE_CAP_STEPS = (1.0, 0.93, 0.85)
GRADE_OVER_COST = 2.0 # 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당) — 넘긴 곳을 가장 적게
STREAM_COST = 3.0 # 계류 버퍼 안 링크 1 m 당 벌점 — 건너기는 되고 나란히 달리기는 피함 SOFT_GRADE_COST = 500.0 # × (넘은 기울기 / 상한) — 50 · 500 · 5000 에서 넘는 곳 수 같음(랩탑_보조)
TURN_COST = 1.0 # 꺾을 때 벌점 = 최소 곡선반지름 원호 길이(R·θ) × 이 값 SOFT_STREAM_COST = 20.0 # 계류 버퍼 안 나란히 달리기
MAX_TURN_STEPS = 2 # 한 걸음에 꺾는 방향 칸 수 한도(16 방향 중 ±2 = 약 45°) BOUND_BISECT = 7 # 지형상 불가능할 때 막는 기울기를 찾는 이분 탐색 횟수(구간 1/128 까지)
STREAM_CROSS_COS = math.cos(math.radians(45.0)) # 버퍼 안 이동 방향 한도(계류 쪽 · 반대쪽 ±45°)
# 16 방향(행, 열) — +x 에서 반시계. 나이트 걸음(1, 2) 등을 넣어 22.5° 안팎 방향을 곧게 달림. # 16 방향(행, 열) — +x 에서 반시계. 나이트 걸음(1, 2) 등을 넣어 22.5° 안팎 방향을 곧게 달림.
DIRECTIONS = ( DIRECTIONS = (
(0, 1), (0, 1),
@@ -93,8 +104,6 @@ DIRECTIONS = (
(-1, 1), (-1, 1),
(-1, 2), (-1, 2),
) )
# 계류 버퍼 안 한 구간이 건너기로 인정되는 길이 — 45° 이상으로 가로지를 때의 버퍼 안 길이.
STREAM_CROSS_MAX_M = 2.0 * STREAM_OFFSET_M * math.sqrt(2.0)
@dataclass @dataclass
@@ -186,6 +195,10 @@ def stream_lines(features: list[dict[str, Any]]) -> list[list[tuple[float, float
return lines return lines
class NoRoute(ValueError):
"""제약을 지키며 시점과 종점을 잇는 길이 없음."""
def generate_initial_route( def generate_initial_route(
expected: list[tuple[float, float]], expected: list[tuple[float, float]],
terrain: Terrain, terrain: Terrain,
@@ -196,30 +209,70 @@ def generate_initial_route(
max_grade: float, max_grade: float,
min_radius_m: float, min_radius_m: float,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14).""" """갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14).
공통 제약을 넘지 않는 길을 먼저 찾고(탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 곡선 맞춤 뒤
지반 차까지 없앰), 그런 길이 없을 때만 벌점 탐색으로 가장 적게 넘긴 길을 낸다.
"""
if mode not in MODES: if mode not in MODES:
raise ValueError(f"모르는 갈래: {mode}") raise ValueError(f"모르는 갈래: {mode}")
if len(expected) < 2: if len(expected) < 2:
raise ValueError("예상노선 정점이 2개 미만입니다.") raise ValueError("예상노선 정점이 2개 미만입니다.")
used = resolve_weights(mode, weights) used = resolve_weights(mode, weights)
points = ( grid = _SearchGrid(expected, terrain, streams)
list(expected)
if mode == "follow" def attempt(hard_cap: float, bound: float | None) -> tuple[Any, dict[str, Any]]:
else _search_route(expected, terrain, streams, used, max_grade, min_radius_m) nodes = _search_route(
) grid,
outline = build_planned_polyline(points, min_radius_m=min_radius_m, simplify=True) expected,
metrics = route_metrics( used,
outline.vertices, limit=max_grade,
expected, hard_cap=hard_cap,
terrain, strict_stream=bound is None,
streams, min_radius_m=min_radius_m,
max_grade=max_grade, )
radius_runs=sum(1 for curve in outline.curves if curve.violations), # 노드마다 곡선(묶지 않음) — [확인] 재구성(`/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 { return {
"mode": mode, "mode": mode,
"logic": mode, "logic": mode,
"weights": used, "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], "planned": [[round(x, 4), round(y, 4)] for x, y in outline.vertices],
"nodes": [node.as_dict() for node in outline.nodes], "nodes": [node.as_dict() for node in outline.nodes],
"curves": [curve.as_dict() for curve in outline.curves], "curves": [curve.as_dict() for curve in outline.curves],
@@ -230,6 +283,7 @@ def generate_initial_route(
"stream_offset_m": STREAM_OFFSET_M, "stream_offset_m": STREAM_OFFSET_M,
"road_width_m": ROAD_WIDTH_M, "road_width_m": ROAD_WIDTH_M,
"corridor_m": CORRIDOR_M, "corridor_m": CORRIDOR_M,
"grade_smooth_m": GRADE_SMOOTH_M,
"pending": list(PENDING), "pending": list(PENDING),
}, },
} }
@@ -261,68 +315,200 @@ def _distance_grid(
return ndimage.distance_transform_edt(~marked) * cell 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]], expected: list[tuple[float, float]],
terrain: Terrain,
streams: list[list[tuple[float, float]]],
weights: dict[str, float], weights: dict[str, float],
max_grade: float, max_grade: float,
min_radius_m: float, min_radius_m: float,
) -> list[tuple[float, float]]: ) -> float:
"""복도 안 격자에서 진행 방향을 기억하는 Dijkstra — 시점 · 종점은 예상노선 양 끝 그대로. """반지름 규칙까지 지키며 시점 · 종점을 잇는 데 **어떤 길도 넘어야 하는** 기울기(비율).
상태 = (칸, 들어온 방향 16 가지). 한 걸음에 22.5° · 45° 까지만 꺾고, 꺾을 때마다 최소 아래 한계 = 꺾기 규칙 없는 최소최대(`_minimax_grade`). 이어지는 값을 1.5 배씩 찾은 뒤
곡선반지름 원호 길이만큼 벌점을 매겨 곧게 달리게 한다. 16 방향(체스 나이트 걸음 포함)이라 그 사이를 탐색 그래프 그대로 이분 탐색한다(계류는 벌점 — 기울기만 따짐).
8 방향 격자가 급한 옆경사에서 등고선을 못 따라 계단처럼 꺾이던 것을 피한다.
""" """
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 당) def joined(cap: float) -> bool:
links: list[tuple[np.ndarray, np.ndarray, float, np.ndarray, np.ndarray]] = [] try:
for dr, dc in DIRECTIONS: _search_route(
first = (slice(max(0, -dr), rows - max(0, dr)), slice(max(0, -dc), cols - max(0, dc))) grid,
second = (slice(max(0, dr), rows - max(0, -dr)), slice(max(0, dc), cols - max(0, -dc))) expected,
a, b = index[first], index[second] weights,
ok = (a >= 0) & (b >= 0) limit=max_grade,
norm = math.hypot(dr, dc) hard_cap=cap,
grade = np.abs(z[second][ok] - z[first][ok]) / (cell * norm) strict_stream=False,
over = np.maximum(0.0, grade - max_grade) min_radius_m=min_radius_m,
# 옆경사 = 진행 방향에 수직인 지반 기울기(두 칸 평균)
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,
) )
) 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): if not any(link[0].size for link in links):
raise ValueError("예상노선 둘레에 탐색할 지형이 없습니다.") raise NoRoute("예상노선 둘레에 제약을 지키며 지날 수 있는 링크가 없습니다.")
# 세 목표 정규화 — 복도 안 링크 평균이 1 이 되게(길이 가중). 평균이 0 이면 그 목표는 0. # 세 목표 정규화 — 복도 안 링크 평균이 1 이 되게(길이 가중). 평균이 0 이면 그 목표는 0.
lengths = np.concatenate([np.full(link[0].size, link[2]) for link in links]) 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) 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) 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] angles = [math.atan2(dr, dc) for dr, dc in DIRECTIONS]
heads, tails, costs = [], [], [] heads, tails, costs = [], [], []
for out, (a, b, length, term, penalty) in enumerate(links): for out, (a, b, length, terms, penalty) in enumerate(links):
move = length * (LENGTH_COST + factor @ term + penalty) move = length * (LENGTH_COST + factor @ terms + penalty)
for turn in range(-MAX_TURN_STEPS, MAX_TURN_STEPS + 1): units = max(1, int(length / grid.cell + 1e-9)) # 곧은 · 대각 1, 나이트 2
came = (out + turn) % count 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) bend = abs((angles[out] - angles[came] + math.pi) % (2.0 * math.pi) - math.pi)
heads.append(a * count + came) heads.append(state(a, came, full))
tails.append(b * count + out) tails.append(state(b, out, min(units + 1, full)))
costs.append(move + TURN_COST * min_radius_m * bend) costs.append(move + TURN_COST * min_radius_m * bend)
size = grid.cells * count * layers
graph = csr_matrix( graph = csr_matrix(
(np.concatenate(costs), (np.concatenate(heads), np.concatenate(tails))), (np.concatenate(costs), (np.concatenate(heads), np.concatenate(tails))),
shape=(cells * count, cells * count), shape=(size, size),
) )
cell_rows, cell_cols = np.nonzero(corridor) start, end = grid.nearest(expected[0]), grid.nearest(expected[-1])
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])
distance, predecessors, _ = dijkstra( distance, predecessors, _ = dijkstra(
graph, graph,
indices=[start * count + d for d in range(count)], indices=[state(start, d, 0) for d in range(count)],
return_predecessors=True, return_predecessors=True,
min_only=True, min_only=True,
) )
state = end * count + int(np.argmin(distance[end * count : (end + 1) * count])) straight = [state(end, d, full) for d in range(count)]
if not np.isfinite(distance[state]): final = min(straight, key=lambda s: distance[s])
raise ValueError("복도 안에서 시점과 종점을 잇는 길을 찾지 못했습니다.") if not np.isfinite(distance[final]):
path = [state] final = min(
while predecessors[path[-1]] >= 0: (state(end, d, w) for d in range(count) for w in range(layers)),
path.append(int(predecessors[path[-1]])) key=lambda s: distance[s],
points = [ )
(float(centres[s // count, 0]), float(centres[s // count, 1])) for s in reversed(path) if not np.isfinite(distance[final]):
] raise NoRoute("복도 안에서 제약을 지키며 시점과 종점을 잇는 길이 없습니다.")
# 양 끝은 격자 칸 가운데가 아니라 예상노선 끝점 그대로 — 기점 · 종점은 옮기지 않는다. chain = [int(final)]
points[0], points[-1] = tuple(expected[0]), tuple(expected[-1]) while predecessors[chain[-1]] >= 0:
return points 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:
def _resample(vertices: list[tuple[float, float]], step: float) -> tuple[np.ndarray, np.ndarray]: cell = previous // layers // count
"""폴리라인을 `step` 간격 점으로 — (점 [n,2], 누가거리 [n]).""" nodes.append((float(grid.centres[cell, 0]), float(grid.centres[cell, 1])))
xy = np.asarray(vertices, dtype=np.float64) nodes.append(tuple(expected[-1]))
seg = np.hypot(*np.diff(xy, axis=0).T) return nodes
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),
}
@@ -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
+69 -43
View File
@@ -1,6 +1,9 @@
"""초기 계획노선 갈래(PLAN 20장) — `B05_Profile_Engine_RouteInitial` 합성 지형 시험. """초기 계획노선 갈래(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 import numpy as np
@@ -12,12 +15,12 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import (
STREAM_OFFSET_M, STREAM_OFFSET_M,
Terrain, Terrain,
generate_initial_route, generate_initial_route,
grade_line,
read_initial_choice, read_initial_choice,
resolve_weights, resolve_weights,
stream_lines, stream_lines,
write_initial_choice, write_initial_choice,
) )
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import grade_line, station_label
GRADE = 0.14 GRADE = 0.14
RADIUS = 12.0 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)] 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: def _run(terrain: Terrain, mode: str, streams=(), weights=None) -> dict:
return generate_initial_route( return generate_initial_route(
_line(200.0), _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: def _kept(result: dict) -> 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)
metrics = result["metrics"] metrics = result["metrics"]
# 언덕을 돌아가 기울기 · 토공이 준다 assert metrics["over_limit"]["total"] == len(metrics["violations"])
assert metrics["max_grade_pct"] < follow["max_grade_pct"] if not metrics["violations"]:
assert metrics["cut_m3"] + metrics["fill_m3"] < follow["cut_m3"] + follow["fill_m3"] assert metrics["max_grade_pct"] <= GRADE * 100.0 + 1e-6
assert metrics["over_limit"]["grade"] <= follow["over_limit"]["grade"] 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) 밖으로 안 나감 # 기점 · 종점은 옮기지 않고 복도(100 m) 밖으로 안 나감
assert result["planned"][0] == [50.0, 200.0] assert result["planned"][0] == [50.0, 200.0]
assert result["planned"][-1] == [750.0, 200.0] assert result["planned"][-1] == [750.0, 200.0]
assert metrics["offset_max_m"] <= result["criteria"]["corridor_m"] + 4.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: def test_weighted_expected_share_pulls_route_back(hill: Terrain) -> None:
near = _run(hill, "weighted", weights={"expected": 90, "earthwork": 5, "grade": 5}) near = _run(hill, "weighted", weights={"expected": 90, "earthwork": 5, "grade": 5})
far = _run(hill, "weighted", weights={"expected": 10, "earthwork": 45, "grade": 45}) 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: def test_stream_offset_is_hard() -> None:
"""평지 · 예상노선 20 m 옆을 나란히 흐르는 소하천 — 추종은 초과 1 · 탐색은 비켜 감.""" """평지 · 예상노선 20 m 옆을 나란히 흐르는 소하천 — 모든 갈래가 버퍼를 비켜 감."""
flat = _terrain(lambda xx, yy: np.zeros_like(xx)) flat = _terrain(lambda xx, yy: np.zeros_like(xx))
stream = [[(0.0, 220.0), (800.0, 220.0)]] stream = [[(0.0, 220.0), (800.0, 220.0)]]
follow = _run(flat, "follow", streams=stream)["metrics"] for mode in MODES:
moved = _run(flat, "earthwork", streams=stream)["metrics"] result = _run(flat, mode, streams=stream)
assert follow["over_limit"]["stream"] == 1 _kept(result)
assert moved["over_limit"]["stream"] == 0 assert result["metrics"]["over_limit"]["stream"] == 0
assert moved["offset_avg_m"] > STREAM_OFFSET_M - 20.0 - 10.0 assert result["metrics"]["offset_avg_m"] > STREAM_OFFSET_M - 20.0 - 30.0
def test_stream_crossing_is_allowed() -> None: def test_stream_crossing_is_allowed() -> None:
flat = _terrain(lambda xx, yy: np.zeros_like(xx)) flat = _terrain(lambda xx, yy: np.zeros_like(xx))
crossing = [[(400.0, 0.0), (400.0, 400.0)]] 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: 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]) ground = np.array([0.0, 0.0, 5.0, 10.0, 10.0, 2.0, 0.0])
line = grade_line(ground, 2.0, GRADE) line = grade_line(ground, 2.0, GRADE)
assert np.all(np.abs(np.diff(line)) <= GRADE * 2.0 + 1e-9) 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: def test_weights_and_stream_filter() -> None:
assert resolve_weights("weighted", None) == {"expected": 50.0, "earthwork": 25.0, "grade": 25.0} assert resolve_weights("weighted", None) == {"expected": 50.0, "earthwork": 25.0, "grade": 25.0}
assert resolve_weights("gentle", {"expected": 99})["grade"] == 100.0 assert resolve_weights("gentle", {"expected": 99})["grade"] == 100.0
assert set(MODES) == {"follow", "earthwork", "gentle", "weighted"} assert set(MODES) == {"follow", "earthwork", "gentle", "weighted"}
line = {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}
features = [ features = [
{ {"properties": {"구분": "세류"}, "geometry": line},
"properties": {"구분": "세류"}, {"properties": {"구분": "소하천"}, "geometry": line},
"geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]},
},
{
"properties": {"구분": "소하천"},
"geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]},
},
] ]
assert len(stream_lines(features)) == 1 assert len(stream_lines(features)) == 1
@@ -128,6 +150,10 @@ def test_unknown_mode_rejected(hill: Terrain) -> None:
_run(hill, "shortest") _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: def test_initial_choice_roundtrip(tmp_path) -> None:
"""[확인] 때 적은 현재 초기값을 다시 읽음 · 없거나 깨지면 추종.""" """[확인] 때 적은 현재 초기값을 다시 읽음 · 없거나 깨지면 추종."""
assert read_initial_choice(tmp_path)["logic"] == "follow" assert read_initial_choice(tmp_path)["logic"] == "follow"