Files
Aislo/common_util/common_util_route_profile.py
T
eomsangdonandClaude Opus 5 07c4a7897c feat(B04): 상세 배수유역 계산 — 관 매설 지점 편집 + 세부유역 분할
관 매설 지점을 기준으로 세부 배수유역을 나누는 기능을 B04 2D 지도에 추가한다.
경계는 B04 전처리 격자(03_road_routing.npz)의 road_slot — 1m 셀마다 물이 도달하는
도로 셀 — 을 담당 관으로 라벨링해 그 경계로 잡는다. 종단 Z는 경계를 긋지 않고
"도로 셀이 어느 관으로 흐르는가"만 정한다.

공용 승격 (B04 관리자 화면과 B05 사용자 화면이 같은 결과를 내야 함)
- common_util_drainage_detail.py: 관 보충(9)·세부유역 분할(10) 알고리즘
- common_util_drainage_context.py: 노선·종단 Z·좌표계 입력 준비
- common_util_drainage_pipes.py: 관 지점 정본 저장소(edits/pipe_points.json)
- common_util_route_profile.py: 종단 Z 해석기(계획고 > 경로 정점 > 지표면 > CSV)
- common_util_surface_sampler.py: B05 종횡단 sampler 이동
- B05 _prepare()의 노선 소스를 원청 계획노선 CSV로 정정(B04 격자와 누가거리 정합)

B04 신규 API
- GET  /{project_id}/drainage/pipe-points   저장분 조회(없으면 자동 생성)
- POST /{project_id}/drainage/detail-basins 편집 중 목록으로 재분할(저장 안 함)
- PUT  /{project_id}/drainage/pipe-points   모델 확정 시 관 지점·세부유역 커밋

B04 화면
- 관 마커 기본/자동/수동 색 구분, 계획선 스냅 드래그 이동
- 계획선 우클릭 "관 매설 추가" / 마커 우클릭 "관 매설 삭제"
- 표시 토글 2그룹(관 매설 / 세부 유역)을 유입 집중점과 분리
- "상세유역 분석" 버튼을 눌렀을 때만 재계산

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:48:28 +09:00

174 lines
7.6 KiB
Python

"""계획노선 종단 Z 해석 (공용).
배수유역 세부 설계는 **노면 물이 어느 쪽으로 흐르는가**로 관 담당 구간을 나눈다. 그래서
노선 정점의 Z가 무엇이냐에 따라 결과가 통째로 달라진다. B04(관리자)와 B05(사용자)가 서로
다른 Z를 쓰면 같은 프로젝트에서 세부유역이 갈라지므로, 어느 Z를 쓸지는 여기 한 곳에서만
정한다(2026-08-01 사용자 지시).
우선순위
① B05 종단 계획고(`longitudinal_sections.data.design_profiles`) — 사용자가 편집을 마친 정본
② B05 경로 정점 Z(`route_points.z`) — 계획고 편집 전 단계
③ 확정 지표면 모델 샘플링 — B05를 아직 지나지 않은 B04 시점의 기본값
④ 원청 계획노선 CSV의 z — 위 셋이 모두 없을 때의 최후 폴백
①②는 B05가 푼 **최적 경로** 기하 위의 값이다. 원청 계획노선과 노선 자체가 다르면 같은
누가거리라도 다른 지점이므로, 노선이 실질적으로 같을 때만 채택하고 아니면 ③으로 내려간다.
DB나 페이지 모듈은 여기서 건드리지 않는다 — 호출부(B04/B05 라우터)가 읽어서 넘긴다.
"""
from __future__ import annotations
import logging
import math
from dataclasses import replace
from typing import Any
import numpy as np
from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex
from common_util.common_util_surface_sampler import SurfaceElevationSampler
logger = logging.getLogger(__name__)
# 노선 동일성 판정 — 시·종점이 이 거리 안이고 연장 차이가 아래 비율 안이면 같은 노선으로 본다.
# 최적 경로는 원청 노선을 따라가되 격자 해상도만큼 흔들리므로 여유를 둔다.
ROUTE_MATCH_ENDPOINT_TOLERANCE_M = 20.0
ROUTE_MATCH_LENGTH_TOLERANCE = 0.05
# Z 출처 표시 — 화면과 로그가 어느 종단을 쓴 결과인지 알 수 있어야 한다.
Z_SOURCE_DESIGN = "design_profile"
Z_SOURCE_ROUTE_POINTS = "route_points"
Z_SOURCE_SURFACE = "surface"
Z_SOURCE_CSV = "csv"
def design_elevation_from_longitudinal(
longitudinal: dict[str, Any], chainage_m: float
) -> float | None:
"""종단 계획선(design_profiles) 샘플을 chainage 기준 선형보간해 계획고를 구한다.
프론트 designElevationAt과 동일 규칙(범위 밖 양 끝값 클램프). 계획선이 없으면
None을 반환해 지반고 폴백/오류 처리를 호출부에 맡긴다.
"""
profiles = longitudinal.get("design_profiles") if isinstance(longitudinal, dict) else None
if not isinstance(profiles, list) or not profiles:
return None
samples = [
s
for s in profiles[0].get("samples", [])
if isinstance(s.get("elevation_m"), (int, float))
and isinstance(s.get("chainage_m"), (int, float))
]
if not samples:
return None
if chainage_m <= samples[0]["chainage_m"]:
return float(samples[0]["elevation_m"])
last = samples[-1]
if chainage_m >= last["chainage_m"]:
return float(last["elevation_m"])
for index in range(1, len(samples)):
previous = samples[index - 1]
current = samples[index]
if chainage_m > current["chainage_m"]:
continue
span = current["chainage_m"] - previous["chainage_m"]
if span <= 0:
return float(current["elevation_m"])
ratio = (chainage_m - previous["chainage_m"]) / span
return float(
previous["elevation_m"] + (current["elevation_m"] - previous["elevation_m"]) * ratio
)
return float(last["elevation_m"])
def routes_match(left: list[RouteVertex], right: list[RouteVertex]) -> bool:
"""두 노선이 실질적으로 같은 노선인지 본다(시·종점 근접 + 연장 유사).
최적 경로는 원청 노선을 격자 위에서 다시 그은 것이라 정점이 하나도 겹치지 않을 수
있다. 그래서 정점 대조가 아니라 끝점과 연장으로만 판단한다.
"""
if len(left) < 2 or len(right) < 2:
return False
start_gap = math.dist((left[0].x, left[0].y), (right[0].x, right[0].y))
end_gap = math.dist((left[-1].x, left[-1].y), (right[-1].x, right[-1].y))
if max(start_gap, end_gap) > ROUTE_MATCH_ENDPOINT_TOLERANCE_M:
return False
left_length = left[-1].chainage_m
right_length = right[-1].chainage_m
if left_length <= 0 or right_length <= 0:
return False
return abs(left_length - right_length) / left_length <= ROUTE_MATCH_LENGTH_TOLERANCE
def resolve_route_profile(
vertices: list[RouteVertex],
*,
route_vertices: list[RouteVertex] | None = None,
longitudinal: dict[str, Any] | None = None,
sampler: SurfaceElevationSampler | None = None,
) -> tuple[list[RouteVertex], str]:
"""계획노선 정점에 종단 Z를 채워 돌려준다. (정점 목록, Z 출처) 형태.
`vertices`는 원청 계획노선(B04·B05 공용 기준선)이고, `route_vertices`는 B05가 푼 최적
경로다. 노선이 같을 때만 ①②를 쓰고, 아니면 ③(지표면 샘플링)으로 내려간다.
"""
if len(vertices) < 2:
return vertices, Z_SOURCE_CSV
matched = bool(route_vertices) and routes_match(vertices, route_vertices or [])
if matched and longitudinal:
elevations = [
design_elevation_from_longitudinal(longitudinal, vertex.chainage_m)
for vertex in vertices
]
if all(value is not None for value in elevations):
logger.info("노선 종단 Z: B05 계획고(design_profiles) 채택 — 정점 %d개", len(vertices))
return (
[replace(v, z=float(z)) for v, z in zip(vertices, elevations)],
Z_SOURCE_DESIGN,
)
if matched and route_vertices:
logger.info("노선 종단 Z: B05 경로 정점(route_points) 채택 — 정점 %d개", len(vertices))
return (
[replace(v, z=interpolate_vertex(route_vertices, v.chainage_m)[2]) for v in vertices],
Z_SOURCE_ROUTE_POINTS,
)
if sampler is not None:
sampled = _sample_z(vertices, sampler)
if sampled is not None:
logger.info("노선 종단 Z: 확정 지표면 샘플링 채택 — 정점 %d개", len(vertices))
return sampled, Z_SOURCE_SURFACE
logger.info("노선 종단 Z: 원청 CSV z 유지 — 정점 %d개", len(vertices))
return vertices, Z_SOURCE_CSV
def _sample_z(
vertices: list[RouteVertex], sampler: SurfaceElevationSampler
) -> list[RouteVertex] | None:
"""확정 지표면에서 노선 정점의 지반고를 뽑는다.
모델 밖으로 나간 정점은 유효한 이웃 정점 값으로 메운다 — 노선 한두 점이 DTM 가장자리를
벗어났다고 종단 전체를 버리면 세부유역을 못 나눈다. 유효한 값이 하나도 없으면 None.
"""
xy = np.array([[vertex.x, vertex.y] for vertex in vertices], dtype=np.float64)
try:
z, valid = sampler.sample_xy(xy)
except (ValueError, OSError) as exc:
logger.warning("노선 종단 Z: 지표면 샘플링 실패 — %s", exc)
return None
if not bool(valid.any()):
logger.warning("노선 종단 Z: 노선이 확정 지표면 범위 밖입니다.")
return None
if not bool(valid.all()):
index = np.arange(z.size)
known = index[valid]
z = np.interp(index, known, z[valid])
logger.info(
"노선 종단 Z: 지표면 밖 정점 %d개를 이웃 값으로 메웠습니다.", int((~valid).sum())
)
return [replace(vertex, z=float(value)) for vertex, value in zip(vertices, z)]