Files
Aislo/common_util/common_util_surface_sampler.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

199 lines
8.4 KiB
Python

"""확정 지표면 모델 표고 sampler (공용).
종·횡단 생성기가 의존하는 최소 표고 조회 인터페이스와, 확정된 지표면 모델
(B04_wf1_Surface/models)을 일괄 XY 표고 sampler로 여는 팩토리를 제공한다.
DTM valid_mask를 footprint로 결합해 데이터가 없는 영역을 임의 표고로 메우지
않는다.
B05 종횡단 전용이었으나 B04 배수유역 세부 설계도 같은 표고면을 써야 해서
common_util로 옮겼다 — 두 화면의 종단 Z가 갈라지면 세부유역 경계가 달라진다
(2026-08-01 구조 개편).
"""
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
import numpy as np
from scipy.interpolate import RegularGridInterpolator
class SurfaceElevationSampler(Protocol):
"""종·횡단 생성기가 의존하는 최소 표고 조회 인터페이스."""
def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""(N, 2) 모델좌표 XY 배열에 대해 (z, valid)를 반환한다."""
@dataclass
class DtmGridSampler:
"""정규 DTM의 표고와 valid_mask를 보수적으로 조회한다.
보간점 주변 네 격자 꼭짓점이 모두 유효할 때만 valid=True로 반환한다.
"""
x: np.ndarray
y: np.ndarray
z: np.ndarray
valid_mask: np.ndarray
def __post_init__(self) -> None:
self.x = np.asarray(self.x, dtype=np.float64).reshape(-1)
self.y = np.asarray(self.y, dtype=np.float64).reshape(-1)
self.z = np.asarray(self.z, dtype=np.float64)
self.valid_mask = np.asarray(self.valid_mask, dtype=bool)
if len(self.x) < 2 or len(self.y) < 2:
raise ValueError("DTM 표고 조회에는 X/Y 축이 각각 2개 이상 필요합니다.")
if self.z.shape != (len(self.y), len(self.x)):
raise ValueError("DTM Z 격자 크기가 X/Y 축과 일치하지 않습니다.")
if self.valid_mask.shape != self.z.shape:
raise ValueError("DTM valid_mask 크기가 Z 격자와 일치하지 않습니다.")
if self.x[0] > self.x[-1]:
self.x = self.x[::-1]
self.z = self.z[:, ::-1]
self.valid_mask = self.valid_mask[:, ::-1]
if self.y[0] > self.y[-1]:
self.y = self.y[::-1]
self.z = self.z[::-1, :]
self.valid_mask = self.valid_mask[::-1, :]
self._interpolator = RegularGridInterpolator(
(self.y, self.x), self.z, method="linear", bounds_error=False, fill_value=np.nan
)
@classmethod
def from_npz(cls, path: Path | str) -> "DtmGridSampler":
with np.load(Path(path), allow_pickle=False) as data:
return cls(data["x"], data["y"], data["z"], data["valid_mask"])
def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
xy = np.asarray(xy, dtype=np.float64)
if xy.ndim != 2 or xy.shape[1] != 2:
raise ValueError("표고 조회 좌표는 (N, 2) XY 배열이어야 합니다.")
if not len(xy):
return np.empty(0, dtype=np.float64), np.empty(0, dtype=bool)
z = np.asarray(self._interpolator(np.column_stack([xy[:, 1], xy[:, 0]])), dtype=np.float64)
ix = np.searchsorted(self.x, xy[:, 0], side="right") - 1
iy = np.searchsorted(self.y, xy[:, 1], side="right") - 1
inside = (ix >= 0) & (iy >= 0) & (ix < len(self.x) - 1) & (iy < len(self.y) - 1)
valid = np.zeros(len(xy), dtype=bool)
selected = np.flatnonzero(inside)
if len(selected):
sx = ix[selected]
sy = iy[selected]
valid[selected] = (
self.valid_mask[sy, sx]
& self.valid_mask[sy, sx + 1]
& self.valid_mask[sy + 1, sx]
& self.valid_mask[sy + 1, sx + 1]
& np.isfinite(z[selected])
)
z[~valid] = np.nan
return z, valid
@dataclass
class CallableSurfaceSampler:
"""테스트와 어댑터에 사용할 함수 기반 sampler."""
function: Callable[[np.ndarray], np.ndarray]
def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
values = np.asarray(self.function(np.asarray(xy, dtype=np.float64)), dtype=np.float64)
if values.shape != (len(xy),):
raise ValueError("표고 함수는 입력 좌표 수와 같은 길이의 배열을 반환해야 합니다.")
valid = np.isfinite(values)
return values, valid
@dataclass
class InterpolatedSurfaceSampler:
"""불규칙/곡면 모델 보간기와 DTM footprint 유효성을 결합한다."""
interpolator: Callable[[np.ndarray], np.ndarray]
footprint: SurfaceElevationSampler | None = None
def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
xy = np.asarray(xy, dtype=np.float64)
values = np.asarray(self.interpolator(xy), dtype=np.float64).reshape(-1)
valid = np.isfinite(values)
if self.footprint is not None:
_, footprint_valid = self.footprint.sample_xy(xy)
valid &= footprint_valid
values[~valid] = np.nan
return values, valid
def build_surface_sampler(
models_dir: Path | str, source_filter: str, method: str, smooth: bool
) -> SurfaceElevationSampler:
"""1단계 확정 모델을 종·횡단용 일괄 XY 표고 sampler로 연다."""
models_dir = Path(models_dir)
smooth_suffix = "_smooth" if smooth and method in {"dtm", "tin"} else ""
dtm_smooth = models_dir / f"dtm_{source_filter}_smooth.npz"
dtm_original = models_dir / f"dtm_{source_filter}.npz"
dtm_path = dtm_smooth if smooth and dtm_smooth.exists() else dtm_original
if not dtm_path.exists():
raise FileNotFoundError(f"기준 DTM이 없습니다: {dtm_path.name}")
footprint = DtmGridSampler.from_npz(dtm_path)
if method == "dtm":
return footprint
if method == "tin":
from scipy.interpolate import LinearNDInterpolator
path = models_dir / f"tin_{source_filter}{smooth_suffix}.npz"
if not path.exists() and smooth_suffix:
path = models_dir / f"tin_{source_filter}.npz"
with np.load(path, allow_pickle=False) as data:
vertices = np.asarray(data["vertices"], dtype=np.float64)
interpolator = LinearNDInterpolator(vertices[:, :2], vertices[:, 2], fill_value=np.nan)
return InterpolatedSurfaceSampler(lambda xy: interpolator(xy), footprint)
if method == "nurbs":
from scipy.interpolate import RectBivariateSpline
path = models_dir / f"nurbs_{source_filter}.npz"
with np.load(path, allow_pickle=False) as data:
control_x = np.asarray(data["control_x"], dtype=np.float64)
control_y = np.asarray(data["control_y"], dtype=np.float64)
control_z = np.asarray(data["control_z"], dtype=np.float64)
degree = int(data["degree"][0]) if "degree" in data else 3
spline = RectBivariateSpline(
control_y,
control_x,
control_z,
kx=min(degree, len(control_y) - 1),
ky=min(degree, len(control_x) - 1),
)
return InterpolatedSurfaceSampler(lambda xy: spline.ev(xy[:, 1], xy[:, 0]), footprint)
if method == "implicit":
from scipy.interpolate import RBFInterpolator
path = models_dir / f"implicit_{source_filter}.npz"
with np.load(path, allow_pickle=False) as data:
centers = np.asarray(data["centers_xy"], dtype=np.float64)
center_z = np.asarray(data["center_z"], dtype=np.float64)
smoothing = float(data["smoothing"][0]) if "smoothing" in data else 0.0
interpolator = RBFInterpolator(
centers,
center_z,
neighbors=min(64, len(centers)),
smoothing=smoothing,
kernel="thin_plate_spline",
)
return InterpolatedSurfaceSampler(lambda xy: interpolator(xy), footprint)
if method == "meshfree":
from scipy.interpolate import LinearNDInterpolator
path = models_dir / f"meshfree_{source_filter}.npz"
with np.load(path, allow_pickle=False) as data:
points = np.asarray(data["points"], dtype=np.float64)
interpolator = LinearNDInterpolator(points[:, :2], points[:, 2], fill_value=np.nan)
return InterpolatedSurfaceSampler(lambda xy: interpolator(xy), footprint)
raise ValueError(f"지원하지 않는 지표면 모델입니다: {method}")