Files
Aislo/common_util/common_util_route_geometry.py
T
eomsangdonandClaude Opus 5 cf14958ea5 fix(B05): 예정노선 정점을 직결 문턱 아래로 좁혀 계획선을 그대로 채택한다
용화 예정노선으로 자동 체인을 돌리자 B05가 400으로 끊겼다.
  세그먼트 1 (BP -> CP1) 경로 탐색 실패: 종단경사 한계(26%)로 통과 경로가 없습니다.

원인은 제어점 간격이다. Solver.py:394 는 제어점 쌍이 direct_link_max_m
(ROUTE_DIRECT_LINK_CELL_FACTOR 2.0 x ROUTE_GRID_RES_M 2.0 = 4.0m) 이하면 격자 탐색
없이 직결해 원청 계획노선을 그대로 보존하고, 경사·곡선 위반은 경고로만 남긴다.

  샘플 CSV  350m / 135점 = 간격 2.6m  -> 대부분 직결   -> 통과(곡선 위반 9건은 경고)
  용화 예정  2136m / 121점 = 간격 17.6m -> 전부 탐색     -> 첫 구간 44.6%에서 실패

기존 프로젝트가 됐던 것은 제약을 만족해서가 아니라 탐색을 타지 않아서였다.

계획노선은 바꿀 수 없는 선이므로(사용자 확정), 같은 직선 위에 점을 더 찍어 간격만
좁힌다. densify_route()는 원래 정점을 모두 남기고 사이만 채운다 — 평면 형상도
연장도 끝점도 그대로다. 문턱과 정확히 같게 두면 부동소수 오차 한 번에 탐색으로
넘어가므로 ROUTE_PLANNED_DENSIFY_SAFETY(0.9)를 곱한다.

실측(용화, classification/dtm/smooth): 트림 후 63정점 -> 조밀화 323정점,
연장 1,070m 불변, 최대 간격 3.50m(문턱 4.0m), 시·종점 동일.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:30:56 +09:00

368 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""계획 노선 기하 공용 유틸 — 정점·누가거리·세류 교차점.
배수유역 분석(B04)과 관 편집·세부유역(B05)이 같은 노선 표현을 써야 하므로 여기 한 곳에만
정의한다. 어느 한쪽 페이지 폴더에 두면 반대 방향 import가 생긴다.
노선 원천은 두 가지다.
· B03에 업로드된 **계획 노선 파일**(CSV) — 배수유역 분석의 입력
· DB `route_points` — B05에서 탐색·확정한 노선
둘 다 같은 `RouteVertex` 목록으로 바꿔 아래 함수들이 그대로 받는다.
"""
from __future__ import annotations
import csv
import logging
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from shapely.geometry import LineString, Point, shape
from config.config_system import SURFACE_ROUTE_EDGE_TRIM_M
logger = logging.getLogger(__name__)
# 계획 노선 CSV 열 이름 후보. B03이 여러 형식을 받게 되므로 흔한 표기를 모두 받아 준다.
_X_KEYS = ("x", "X", "동", "easting", "EASTING")
_Y_KEYS = ("y", "Y", "북", "northing", "NORTHING")
_Z_KEYS = ("z", "Z", "표고", "elevation", "ELEV")
_ORDER_KEYS = ("sequence", "order", "seq", "no", "번호")
_EPSG_KEYS = ("crs_epsg", "epsg", "EPSG")
@dataclass
class RouteVertex:
"""노선 폴리라인의 한 점. chainage는 시점 기준 누가거리(m)."""
x: float
y: float
z: float
chainage_m: float
@dataclass
class StructureCandidate:
"""관 매설 구조물 측점 후보."""
chainage_m: float
x: float
y: float
# "stream"=세류 교차, "spacing"=최대 간격 규칙 보충, "confirmed"=사용자 확정
reason: str
stream_name: str | None = None
@dataclass
class PlannedRoute:
"""계획 노선 파일에서 읽은 노선."""
vertices: list[RouteVertex]
epsg: int | None
name: str | None
source: Path
@property
def line(self) -> LineString:
return LineString([(vertex.x, vertex.y) for vertex in self.vertices])
def read_planned_route_csv(path: Path) -> PlannedRoute | None:
"""계획 노선 CSV를 읽어 정점 목록으로 바꾼다.
열 이름은 대소문자·한글 표기를 함께 받아 준다(B03이 여러 형식을 수용할 예정).
`sequence`가 있으면 그 순서로 정렬하고, 없으면 파일에 적힌 순서를 그대로 쓴다.
"""
try:
with path.open("r", encoding="utf-8-sig", newline="") as file:
rows = list(csv.DictReader(file))
except (OSError, csv.Error, UnicodeDecodeError):
logger.warning("계획 노선 CSV를 읽지 못했습니다: %s", path)
return None
if not rows:
return None
epsg = _first_int(rows[0], _EPSG_KEYS)
name = _first_text(rows[0], ("route_name", "name", "노선명"))
parsed: list[tuple[float, float, float, float]] = [] # (정렬키, x, y, z)
for index, row in enumerate(rows):
x = _first_float(row, _X_KEYS)
y = _first_float(row, _Y_KEYS)
if x is None or y is None:
continue
order = _first_float(row, _ORDER_KEYS)
parsed.append(
(float(index) if order is None else order, x, y, _first_float(row, _Z_KEYS) or 0.0)
)
if len(parsed) < 2:
logger.warning("계획 노선 CSV에 좌표가 2점 미만입니다: %s", path)
return None
parsed.sort(key=lambda item: item[0])
vertices: list[RouteVertex] = []
cumulative = 0.0
previous: tuple[float, float] | None = None
for _, x, y, z in parsed:
if previous is not None:
cumulative += math.dist(previous, (x, y))
vertices.append(RouteVertex(x=x, y=y, z=z, chainage_m=cumulative))
previous = (x, y)
logger.info(
"계획 노선 %s: 정점 %d개, 연장 %.0fm, EPSG %s", path.name, len(vertices), cumulative, epsg
)
return PlannedRoute(vertices=vertices, epsg=epsg, name=name, source=path)
def find_planned_route_file(input_dir: Path) -> Path | None:
"""B03 입력 폴더에서 계획 노선 파일을 찾는다. 여러 개면 가장 최근 것."""
if not input_dir.exists():
return None
candidates = sorted(
input_dir.rglob("*.csv"), key=lambda item: item.stat().st_mtime, reverse=True
)
return candidates[0] if candidates else None
def trim_route_to_surface(
points: list[tuple[float, float]],
sampler: Any,
edge_trim_m: float = SURFACE_ROUTE_EDGE_TRIM_M,
) -> list[tuple[float, float]]:
"""지표면이 덮는 구간만 남기고 계획노선을 자른다.
판정은 sampler가 돌려주는 `valid` — 확정 DTM의 valid_mask가 곧 불규칙한 실제
외곽이다(bounds 사각형이 아니다). 가장 긴 연속 유효 구간을 남긴다.
`edge_trim_m`은 **잘라 낸 쪽 끝에만** 적용한다. 서피스 가장자리는 점 밀도가 떨어져
지반고가 못 미덥기 때문이다. 노선 본래 끝점이 서피스 안이면 깎지 않는다 — 멀쩡한
구간을 짧게 만들 이유가 없다 (2026-09-01 사용자 확정).
전부 유효하면 입력을 그대로 돌려준다. 남는 구간이 2점 미만이면 빈 목록.
"""
import numpy as np
if len(points) < 2:
return list(points)
xy = np.asarray(points, dtype=np.float64)
try:
_, valid = sampler.sample_xy(xy)
except (ValueError, OSError) as exc:
logger.warning("노선 트림: 지표면 샘플링 실패 — %s", exc)
return list(points)
valid = np.asarray(valid, dtype=bool)
if valid.all():
return list(points)
if not valid.any():
logger.warning("노선 트림: 노선 전체가 지표면 밖입니다.")
return []
# 가장 긴 연속 유효 구간 — 가장자리에서 한두 점이 튀어도 본 구간을 잃지 않는다.
best_start = best_end = start = -1
for index, ok in enumerate([*valid.tolist(), False]):
if ok and start < 0:
start = index
elif not ok and start >= 0:
if index - start > best_end - best_start:
best_start, best_end = start, index
start = -1
kept = [(float(x), float(y)) for x, y in xy[best_start:best_end]]
trim_head = best_start > 0
trim_tail = best_end < len(xy)
kept = _trim_ends(kept, edge_trim_m if trim_head else 0.0, edge_trim_m if trim_tail else 0.0)
logger.info(
"노선 트림: 정점 %d개 → %d개 (지표면 밖 %d개, 가장자리 여유 %.0fm %s)",
len(points),
len(kept),
int((~valid).sum()),
edge_trim_m,
"앞뒤" if trim_head and trim_tail else ("앞" if trim_head else "뒤"),
)
return kept
def densify_route(
points: list[tuple[float, float]], max_spacing_m: float
) -> list[tuple[float, float]]:
"""원래 정점은 모두 남기고, 간격이 `max_spacing_m`을 넘는 구간에만 점을 끼워 넣는다.
**평면 형상은 바뀌지 않는다** — 같은 직선 위에 점을 더 찍을 뿐이다.
B05 경로 탐색은 제어점 간격이 `ROUTE_DIRECT_LINK_CELL_FACTOR × ROUTE_GRID_RES_M`
이하일 때만 격자 탐색을 건너뛰고 원좌표를 그대로 잇는다
(`B05_Profile_Engine_Solver.py:394`). 예정노선처럼 정점이 성긴 선(용화 평균 17.6m)은
그 문턱을 넘어 탐색을 타고, 급경사 현에서 "통과 경로 없음"으로 끊긴다.
간격만 좁혀 주면 계획노선이 손대지 않은 채 그대로 채택된다 (2026-09-01 사용자 확정).
"""
if len(points) < 2 or max_spacing_m <= 0:
return list(points)
dense: list[tuple[float, float]] = [points[0]]
for start, end in zip(points, points[1:]):
distance = math.dist(start, end)
steps = int(math.ceil(distance / max_spacing_m))
for step in range(1, steps):
ratio = step / steps
dense.append(
(
start[0] + (end[0] - start[0]) * ratio,
start[1] + (end[1] - start[1]) * ratio,
)
)
dense.append(end)
return dense
def _trim_ends(
points: list[tuple[float, float]], head_m: float, tail_m: float
) -> list[tuple[float, float]]:
"""폴리라인 앞뒤에서 지정 길이만큼 잘라 낸다. 남는 게 2점 미만이면 빈 목록."""
if len(points) < 2 or (head_m <= 0 and tail_m <= 0):
return points
line = LineString(points)
start = min(head_m, line.length)
end = max(start, line.length - tail_m)
if end - start <= 0:
logger.warning("노선 트림: 여유를 깎고 나니 남는 구간이 없습니다.")
return []
cumulative = 0.0
kept: list[tuple[float, float]] = [line.interpolate(start).coords[0]]
for index in range(1, len(points)):
cumulative += math.dist(points[index - 1], points[index])
if start < cumulative < end:
kept.append(points[index])
kept.append(line.interpolate(end).coords[0])
return kept if len(kept) >= 2 else []
def build_route_vertices(points: list[dict[str, Any]]) -> list[RouteVertex]:
"""DB route_points 행을 누가거리가 채워진 정점 목록으로 바꾼다."""
vertices: list[RouteVertex] = []
cumulative = 0.0
previous: tuple[float, float] | None = None
for row in points:
x = float(row["x"])
y = float(row["y"])
z = float(row.get("z") or 0.0)
if previous is not None:
cumulative += math.dist(previous, (x, y))
chainage = row.get("chainage_m")
vertices.append(
RouteVertex(
x=x, y=y, z=z, chainage_m=float(chainage) if chainage is not None else cumulative
)
)
previous = (x, y)
return vertices
def interpolate_vertex(
vertices: list[RouteVertex], chainage_m: float
) -> tuple[float, float, float]:
"""누가거리 위치의 (x, y, z)를 선형 보간한다. 범위 밖은 끝점으로 당긴다."""
if not vertices:
return (0.0, 0.0, 0.0)
if chainage_m <= vertices[0].chainage_m:
return (vertices[0].x, vertices[0].y, vertices[0].z)
for previous, current in zip(vertices, vertices[1:]):
if chainage_m <= current.chainage_m:
span = current.chainage_m - previous.chainage_m
ratio = 0.0 if span <= 0 else (chainage_m - previous.chainage_m) / span
return (
previous.x + (current.x - previous.x) * ratio,
previous.y + (current.y - previous.y) * ratio,
previous.z + (current.z - previous.z) * ratio,
)
last = vertices[-1]
return (last.x, last.y, last.z)
def is_uphill_at(vertices: list[RouteVertex], chainage_m: float, window_m: float = 20.0) -> bool:
"""해당 위치가 오르막(절토부)인지 종단 계획선의 국소 기울기 부호로 판정한다."""
_, _, back_z = interpolate_vertex(vertices, max(0.0, chainage_m - window_m))
_, _, forward_z = interpolate_vertex(vertices, chainage_m + window_m)
return forward_z >= back_z
def find_stream_crossings(
vertices: list[RouteVertex],
stream_features: list[dict[str, Any]],
) -> list[StructureCandidate]:
"""노선 평면 선형과 세류선의 교차 지점을 누가거리 순으로 찾는다."""
if len(vertices) < 2:
return []
route_line = LineString([(vertex.x, vertex.y) for vertex in vertices])
candidates: list[StructureCandidate] = []
for feature in stream_features:
geometry = feature.get("geometry")
if not geometry:
continue
try:
stream = shape(geometry)
except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다
continue
if stream.is_empty:
continue
intersection = route_line.intersection(stream)
if intersection.is_empty:
continue
name = _stream_name(feature)
for point in _collect_points(intersection):
candidates.append(
StructureCandidate(
chainage_m=route_line.project(point),
x=point.x,
y=point.y,
reason="stream",
stream_name=name,
)
)
candidates.sort(key=lambda item: item.chainage_m)
return candidates
def _stream_name(feature: dict[str, Any]) -> str | None:
properties = feature.get("properties") or {}
for key in ("명칭", "하천명", "NAME", "name"):
value = properties.get(key)
if value:
return str(value)
return None
def _collect_points(geometry: Any) -> list[Point]:
"""교차 결과(Point/MultiPoint/LineString 등)에서 대표 점들을 뽑는다."""
if geometry.geom_type == "Point":
return [geometry]
if geometry.geom_type in {"MultiPoint", "GeometryCollection"}:
points: list[Point] = []
for part in geometry.geoms:
points.extend(_collect_points(part))
return points
# 선분끼리 겹쳐 선으로 나온 경우는 중점을 대표로 쓴다.
if geometry.geom_type in {"LineString", "MultiLineString"}:
return [geometry.interpolate(0.5, normalized=True)]
return []
def _first_text(row: dict[str, Any], keys: tuple[str, ...]) -> str | None:
for key in keys:
value = row.get(key)
if value not in (None, ""):
return str(value).strip()
return None
def _first_float(row: dict[str, Any], keys: tuple[str, ...]) -> float | None:
text = _first_text(row, keys)
if text is None:
return None
try:
return float(text)
except ValueError:
return None
def _first_int(row: dict[str, Any], keys: tuple[str, ...]) -> int | None:
value = _first_float(row, keys)
return None if value is None else int(value)