- 종단기울기 상한 넘는 링크 · 계류 버퍼 안 나란히 달리기는 탐색에서 뺌(추종 갈래 포함) - 최소 곡선반지름: 이웃 방향으로만 꺾고 접선 길이만큼 곧게 간 뒤 다시 꺾음 · 노드마다 원호 - 길이 없을 때만 막은 기울기(규칙 지키는 어떤 길도 넘어야 하는 값)를 이분 탐색해 그 위는 막고 넘긴 곳을 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
571 lines
26 KiB
Python
571 lines
26 KiB
Python
"""초기 계획노선 갈래 — 예상노선 추종 · 토공 최소 · 기울기 순한 노선 · 비중(PLAN 20장).
|
|
|
|
계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이
|
|
받은 노드로 기존 `POST /route/replan`([확인])을 부른다.
|
|
|
|
갈래(`mode`):
|
|
· follow — 예상노선 이격 최소(예상노선을 따르되 공통 제약은 지킴).
|
|
· earthwork — 절 · 성토 합 최소(DEM 옆경사로 가늠한 단면 토량).
|
|
· gentle — 종단기울기 최소(지반 기울기 제곱 합).
|
|
· weighted — 예상노선 이격 · 토공 · 기울기 세 목표를 **정규화해 가중합**(합 100).
|
|
정규화 = 목표마다 복도 안 링크 평균값으로 나눔 — 단위가 다른 세 값을 1 m 당
|
|
「보통 크기 1」로 맞춘 뒤 비중을 곱한다.
|
|
탐색 = 예상노선 둘레 복도(`CORRIDOR_M`) 안 `SEARCH_CELL_M` 격자 · 16 방향 Dijkstra(scipy csgraph).
|
|
상태 = (칸, 진행 방향, 꺾은 뒤 곧게 온 걸음 수). 탐색 경로의 꺾임점이 곧 노드이고, 노드마다
|
|
최소 곡선반지름 원호를 끼운다(`build_planned_polyline` · 노드별 곡선 — [확인] 재구성과 같은 규칙).
|
|
|
|
공통 제약(모든 갈래 · **넘지 못함**) — 종단기울기 상한 · 최소 곡선반지름 · 계류 이격:
|
|
· 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌. 곡선 맞춤 뒤 측점(20 m) 사이
|
|
지반이 상한을 넘으면 탐색 상한을 조금씩 낮춰 다시 찾음(`GRADE_CAP_STEPS`).
|
|
· 최소 곡선반지름 — 한 번에 이웃 방향(약 22.5°)으로만 꺾고, 꺾은 뒤 원호 두 개의 접선 길이만큼
|
|
곧게 가야 다시 꺾음 → 노드마다 최소 반지름 원호가 들어감.
|
|
· 계류 이격 — 버퍼 안에서는 계류 쪽 · 반대쪽 ±45° 로만 움직임(건너기 · 빠져나가기).
|
|
· 그래도 길이 없을 때만(지형상 불가능) 벌점 탐색으로 가장 적게 넘긴 길을 내고, 넘긴 곳을
|
|
`metrics.violations`(측점 · 값 · 사유)에 적음. `over_limit` 수 = 그 목록 수.
|
|
|
|
⚠ 사용자 확정 대기 — 아래 임시값과 호출부가 넘기는 종단기울기 상한 · 최소 곡선반지름은 지식DB
|
|
(`resources/knowledge/technical_info/01_임도`) 후보일 뿐이다(PLAN 20장 기준값 후보 표).
|
|
확정되면 이 머리의 상수만 고친다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
from scipy import ndimage
|
|
from scipy.sparse import csr_matrix
|
|
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장) ─────────────────────────────────────
|
|
# 계류 이격(m) — 법정 이격 수치 없음. 타당성평가 「산지계류 100 m 이내 구간 비율」(별표1 비고,
|
|
# 1/25,000 주요 수계)을 후보로 씀.
|
|
STREAM_OFFSET_M = 100.0
|
|
# 이격을 따질 계류 — 도엽 하천중심선 `구분`. 「주요 수계」 가늠으로 세류 · 연결선은 뺌.
|
|
STREAM_CLASSES = ("소하천", "지방하천", "국가하천")
|
|
# 토공 가늠 폭(m) = 유효너비 3 + 길어깨 0.5 × 2(별표2 간선).
|
|
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",
|
|
"grade_smooth_m",
|
|
)
|
|
|
|
# ── 탐색 · 측정 값 ───────────────────────────────────────────────────────────────
|
|
MODES = ("follow", "earthwork", "gentle", "weighted")
|
|
DEFAULT_WEIGHTS = {"expected": 50.0, "earthwork": 25.0, "grade": 25.0}
|
|
MODE_WEIGHTS = {
|
|
"follow": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0},
|
|
"earthwork": {"expected": 0.0, "earthwork": 100.0, "grade": 0.0},
|
|
"gentle": {"expected": 0.0, "earthwork": 0.0, "grade": 100.0},
|
|
}
|
|
SEARCH_CELL_M = 4.0 # 탐색 격자 칸(m) — 비용면(2 m)을 솎아 씀
|
|
LENGTH_COST = 0.05 # 1 m 당 거리 비용 — 목표가 평평한 곳에서 헤매지 않게
|
|
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),
|
|
(1, 2),
|
|
(1, 1),
|
|
(2, 1),
|
|
(1, 0),
|
|
(2, -1),
|
|
(1, -1),
|
|
(1, -2),
|
|
(0, -1),
|
|
(-1, -2),
|
|
(-1, -1),
|
|
(-2, -1),
|
|
(-1, 0),
|
|
(-2, 1),
|
|
(-1, 1),
|
|
(-1, 2),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Terrain:
|
|
"""지반 격자 — 탐색 비용면(`B05_Profile_Engine_Solver._load_or_build_cost_surface`) 그대로."""
|
|
|
|
x: np.ndarray # 열 좌표(오름차순)
|
|
y: np.ndarray # 행 좌표(오름차순)
|
|
z: np.ndarray # [행, 열] 표고
|
|
valid: np.ndarray # [행, 열] 지형 있음
|
|
|
|
|
|
def load_terrain_and_streams(
|
|
project_root: Path, selection: dict[str, Any]
|
|
) -> tuple[Terrain, list[list[tuple[float, float]]]]:
|
|
"""지반 격자(탐색 비용면 캐시 그대로)와 이격을 따질 계류(사업지 좌표계)를 읽는다."""
|
|
from pyproj import Transformer
|
|
|
|
from B04_PreProcess.B04_PreProcess_Router_Watershed import (
|
|
STREAM_FILE,
|
|
_load_features,
|
|
_reproject_features,
|
|
)
|
|
from B05_Profile.B05_Profile_Engine_Solver import _MODELS_SUBDIR, _load_or_build_cost_surface
|
|
from common_util.common_util_crs import resolve_project_crs
|
|
|
|
x, y, z, valid, *_ = _load_or_build_cost_surface(
|
|
project_root,
|
|
project_root / _MODELS_SUBDIR,
|
|
str(selection.get("source_filter")),
|
|
str(selection.get("method") or "dtm"),
|
|
bool(selection.get("smooth")),
|
|
)
|
|
to_metric = Transformer.from_crs("EPSG:4326", resolve_project_crs(project_root), always_xy=True)
|
|
features = _load_features(project_root / "B04_PreProcess" / "processed", STREAM_FILE)
|
|
streams = stream_lines(_reproject_features(features, to_metric))
|
|
return Terrain(np.asarray(x), np.asarray(y), np.asarray(z), np.asarray(valid)), streams
|
|
|
|
|
|
def _choice_path(project_root: Path) -> Path:
|
|
return project_root / "B05_Profile" / "route" / "initial_choice.json"
|
|
|
|
|
|
def read_initial_choice(project_root: Path) -> dict[str, Any]:
|
|
"""현재 초기값(갈래 · 비중) — [확인] 때 적어 둔 것. 없거나 못 읽으면 추종."""
|
|
try:
|
|
data = json.loads(_choice_path(project_root).read_text(encoding="utf-8"))
|
|
logic = data["logic"] if data.get("logic") in MODES else "follow"
|
|
return {"logic": logic, "weights": resolve_weights(logic, data.get("weights"))}
|
|
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
|
return {"logic": "follow", "weights": resolve_weights("follow", None)}
|
|
|
|
|
|
def write_initial_choice(
|
|
project_root: Path, logic: str, weights: dict[str, float] | None
|
|
) -> dict[str, Any]:
|
|
"""[확인] 때만 부름 — 작업본 한 칸. 다시 열기 · 다른 PC 에서도 같은 초기값."""
|
|
choice = {"logic": logic, "weights": resolve_weights(logic, weights)}
|
|
path = _choice_path(project_root)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(choice, ensure_ascii=False), encoding="utf-8")
|
|
return choice
|
|
|
|
|
|
def resolve_weights(mode: str, weights: dict[str, float] | None) -> dict[str, float]:
|
|
"""갈래의 비중(합 100). weighted 가 아니면 갈래가 정한 값."""
|
|
if mode != "weighted":
|
|
return dict(MODE_WEIGHTS[mode])
|
|
merged = {**DEFAULT_WEIGHTS, **(weights or {})}
|
|
return {key: float(merged[key]) for key in DEFAULT_WEIGHTS}
|
|
|
|
|
|
def stream_lines(features: list[dict[str, Any]]) -> list[list[tuple[float, float]]]:
|
|
"""이격을 따질 계류만 좌표 목록으로(사업지 좌표계로 이미 옮긴 피처)."""
|
|
lines: list[list[tuple[float, float]]] = []
|
|
for feature in features:
|
|
if (feature.get("properties") or {}).get("구분") not in STREAM_CLASSES:
|
|
continue
|
|
geometry = feature.get("geometry") or {}
|
|
parts = geometry.get("coordinates") or []
|
|
if geometry.get("type") == "LineString":
|
|
parts = [parts]
|
|
elif geometry.get("type") != "MultiLineString":
|
|
continue
|
|
for part in parts:
|
|
points = [(float(p[0]), float(p[1])) for p in part if len(p) >= 2]
|
|
if len(points) >= 2:
|
|
lines.append(points)
|
|
return lines
|
|
|
|
|
|
class NoRoute(ValueError):
|
|
"""제약을 지키며 시점과 종점을 잇는 길이 없음."""
|
|
|
|
|
|
def generate_initial_route(
|
|
expected: list[tuple[float, float]],
|
|
terrain: Terrain,
|
|
streams: list[list[tuple[float, float]]],
|
|
*,
|
|
mode: str,
|
|
weights: dict[str, float] | None,
|
|
max_grade: float,
|
|
min_radius_m: float,
|
|
) -> dict[str, Any]:
|
|
"""갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `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)
|
|
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],
|
|
"metrics": metrics,
|
|
"criteria": {
|
|
"max_grade_pct": round(max_grade * 100.0, 2),
|
|
"min_radius_m": round(min_radius_m, 2),
|
|
"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),
|
|
},
|
|
}
|
|
|
|
|
|
# ── 탐색 ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _cell_size(coords: np.ndarray) -> float:
|
|
return float((coords[-1] - coords[0]) / (len(coords) - 1)) if len(coords) > 1 else 1.0
|
|
|
|
|
|
def _distance_grid(
|
|
lines: list[list[tuple[float, float]]], xs: np.ndarray, ys: np.ndarray, cell: float
|
|
) -> np.ndarray:
|
|
"""격자 칸마다 선까지 거리(m). 선이 격자에 안 닿으면 모두 무한."""
|
|
marked = np.zeros((len(ys), len(xs)), dtype=bool)
|
|
for line in lines:
|
|
for (x0, y0), (x1, y1) in zip(line, line[1:]):
|
|
count = max(2, int(math.hypot(x1 - x0, y1 - y0) / (cell * 0.5)) + 1)
|
|
px = np.linspace(x0, x1, count)
|
|
py = np.linspace(y0, y1, count)
|
|
cols = np.rint((px - xs[0]) / cell).astype(int)
|
|
rows = np.rint((py - ys[0]) / cell).astype(int)
|
|
inside = (cols >= 0) & (cols < len(xs)) & (rows >= 0) & (rows < len(ys))
|
|
marked[rows[inside], cols[inside]] = True
|
|
if not marked.any():
|
|
return np.full(marked.shape, np.inf)
|
|
return ndimage.distance_transform_edt(~marked) * cell
|
|
|
|
|
|
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]],
|
|
weights: dict[str, float],
|
|
max_grade: float,
|
|
min_radius_m: float,
|
|
) -> float:
|
|
"""반지름 규칙까지 지키며 시점 · 종점을 잇는 데 **어떤 길도 넘어야 하는** 기울기(비율).
|
|
|
|
아래 한계 = 꺾기 규칙 없는 최소최대(`_minimax_grade`). 이어지는 값을 1.5 배씩 찾은 뒤
|
|
그 사이를 탐색 그래프 그대로 이분 탐색한다(계류는 벌점 — 기울기만 따짐).
|
|
"""
|
|
|
|
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 NoRoute("예상노선 둘레에 제약을 지키며 지날 수 있는 링크가 없습니다.")
|
|
|
|
# 세 목표 정규화 — 복도 안 링크 평균이 1 이 되게(길이 가중). 평균이 0 이면 그 목표는 0.
|
|
lengths = np.concatenate([np.full(link[0].size, link[2]) for link in links])
|
|
stacked = np.hstack([link[3] for link in links])
|
|
scale = (stacked * lengths).sum(axis=1) / max(lengths.sum(), 1e-9)
|
|
order = ("expected", "earthwork", "grade")
|
|
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)
|
|
|
|
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, 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(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=(size, size),
|
|
)
|
|
start, end = grid.nearest(expected[0]), grid.nearest(expected[-1])
|
|
distance, predecessors, _ = dijkstra(
|
|
graph,
|
|
indices=[state(start, d, 0) for d in range(count)],
|
|
return_predecessors=True,
|
|
min_only=True,
|
|
)
|
|
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
|