Files
Aislo/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py
T

246 lines
9.5 KiB
Python

"""배수유역도 API 라우터.
구조물 측점(관 매설) 후보 제안과 배수유역 산정을 제공한다. 지형 근거는 도엽 등고선·세류선·
표고점 GeoJSON뿐이며, 좌표는 사업지 CRS(m)에서 계산하고 응답만 WGS84로 바꿔 내보낸다.
"""
import json
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pyproj import Transformer
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import (
StructureCandidate,
build_route_vertices,
propose_structure_stations,
)
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Watershed import build_watershed_basins
from B05_wf2_Route.B05_wf2_Route_Repository import (
get_latest_route,
get_route_points,
get_surface_crs_epsg,
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"])
# 도엽 레이어 파일명 (B04 전처리 산출물과 동일 위치)
_CONTOUR_FILE = "도엽_등고선.geojson"
_STREAM_FILE = "도엽_하천중심선.geojson"
_SPOT_FILE = "도엽_표고점.geojson"
# 표고 속성 키: 도엽 등고선(등고수치)·gpkg 등고선(CTRLN_HG)·표고점(수치/표고) 통합.
_ELEVATION_KEYS = ("등고수치", "CTRLN_HG", "수치", "표고", "높이", "elevation", "ELEV")
def _sheet_dir(stored_path: str) -> Path:
return Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" / "processed"
def _load_features(directory: Path, filename: str) -> list[dict[str, Any]]:
"""도엽 GeoJSON을 읽어 피처 목록만 돌려준다. 없으면 빈 목록."""
path = directory / filename
if not path.exists():
return []
try:
with path.open("r", encoding="utf-8") as file:
data = json.load(file)
except (OSError, json.JSONDecodeError):
logger.warning("도엽 GeoJSON을 읽지 못했습니다: %s", path)
return []
features = data.get("features")
return features if isinstance(features, list) else []
def _reproject_features(
features: list[dict[str, Any]],
transformer: Transformer | None,
) -> list[dict[str, Any]]:
"""WGS84 도엽 좌표를 사업지 CRS(m)로 바꾼다. 거리·면적을 미터로 계산하기 위함."""
if transformer is None:
return features
converted: list[dict[str, Any]] = []
for feature in features:
geometry = feature.get("geometry")
if not geometry:
continue
coordinates = _map_coordinates(geometry.get("coordinates"), transformer)
if coordinates is None:
continue
converted.append(
{
"type": "Feature",
"properties": feature.get("properties") or {},
"geometry": {"type": geometry.get("type"), "coordinates": coordinates},
}
)
return converted
def _map_coordinates(coordinates: Any, transformer: Transformer) -> Any:
"""중첩 좌표 배열을 재귀적으로 변환한다."""
if not isinstance(coordinates, list) or not coordinates:
return None
first = coordinates[0]
if isinstance(first, (int, float)):
x, y = transformer.transform(float(coordinates[0]), float(coordinates[1]))
return [x, y]
mapped = [_map_coordinates(item, transformer) for item in coordinates]
return [item for item in mapped if item is not None]
def _candidate_payload(
candidate: StructureCandidate,
to_lonlat: Any,
) -> dict[str, Any]:
lon, lat = to_lonlat(candidate.x, candidate.y)
return {
"chainage_m": round(candidate.chainage_m, 2),
"x": candidate.x,
"y": candidate.y,
"lon": lon,
"lat": lat,
"reason": candidate.reason,
"stream_name": candidate.stream_name,
}
async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse:
"""노선 정점·도엽 피처·좌표 변환기를 한 번에 준비한다."""
pool = get_db_pool()
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
route = await get_latest_route(connection, project_id)
if not route:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "확정된 경로가 없습니다."},
)
points = await get_route_points(connection, int(route["id"]))
surface_model_id = route.get("surface_model_id")
epsg = await get_surface_crs_epsg(
connection, project_id, int(surface_model_id) if surface_model_id else 0
)
vertices = build_route_vertices(points)
if len(vertices) < 2:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "노선 좌표가 부족합니다."},
)
source_crs = f"EPSG:{epsg}" if epsg else "EPSG:5186"
to_lonlat_transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True)
to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True)
directory = _sheet_dir(stored_path)
streams = _reproject_features(_load_features(directory, _STREAM_FILE), to_metric_transformer)
contour_features = _reproject_features(
_load_features(directory, _CONTOUR_FILE), to_metric_transformer
)
spot_features = _reproject_features(
_load_features(directory, _SPOT_FILE), to_metric_transformer
)
return {
"route_id": int(route["id"]),
"vertices": vertices,
"streams": streams,
"contours": contour_features,
"spots": spot_features,
"to_lonlat": lambda x, y: to_lonlat_transformer.transform(x, y),
}
@router.get("/{project_id}/drainage/candidates", response_model=None)
async def get_structure_candidates(project_id: UUID) -> dict[str, Any] | JSONResponse:
"""관 매설 구조물 측점 후보를 제안한다(세류 교차 + 300m 보충, 성토부 제외)."""
prepared = await _prepare(project_id)
if isinstance(prepared, JSONResponse):
return prepared
candidates = propose_structure_stations(prepared["vertices"], prepared["streams"])
to_lonlat = prepared["to_lonlat"]
return {
"status": "success",
"project_id": str(project_id),
"route_id": prepared["route_id"],
"candidates": [_candidate_payload(candidate, to_lonlat) for candidate in candidates],
}
@router.post("/{project_id}/drainage/basins", response_model=None)
async def post_drainage_basins(
project_id: UUID,
payload: dict[str, Any] | None = None,
) -> dict[str, Any] | JSONResponse:
"""확정된 구조물 측점별 배수유역을 산정한다.
payload에 `chainages`(누가거리 목록)를 주면 그 위치로 확정하고, 없으면 자동 제안분을 쓴다.
"""
prepared = await _prepare(project_id)
if isinstance(prepared, JSONResponse):
return prepared
vertices = prepared["vertices"]
chainages = (payload or {}).get("chainages")
if isinstance(chainages, list) and chainages:
candidates = _candidates_from_chainages(vertices, chainages)
else:
candidates = propose_structure_stations(vertices, prepared["streams"])
basins = build_watershed_basins(
vertices,
candidates,
prepared["contours"],
prepared["spots"],
_ELEVATION_KEYS,
stream_features=prepared["streams"],
)
to_lonlat = prepared["to_lonlat"]
return {
"status": "success",
"project_id": str(project_id),
"route_id": prepared["route_id"],
# 계획선 위 배관(관 매설) 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록.
"pipes": [
_candidate_payload(candidate, to_lonlat)
for candidate in sorted(candidates, key=lambda item: item.chainage_m)
],
"basins": [
{
"index": basin.index,
"chainage_m": round(basin.chainage_m, 2),
# 관(배관) 매설 지점 좌표 — 계획선 위 마커 렌더용.
"outlet_lonlat": list(to_lonlat(basin.outlet_x, basin.outlet_y)),
# 유역 경계 외곽선 = 분수령(능선). 프론트가 파스텔 채움 + 능선 파선으로 표시한다.
"polygon_lonlat": [list(to_lonlat(x, y)) for x, y in basin.boundary_xy],
"area_m2": round(basin.area_m2, 1),
"relief_m": round(basin.relief_m, 2),
"flow_length_m": round(basin.flow_length_m, 1),
# 관경 수식 미확정 — 산정 함수가 None을 돌려주면 프론트가 "미정"으로 표기한다.
"pipe_diameter_mm": basin.pipe_diameter_mm,
}
for basin in basins
],
}
def _candidates_from_chainages(vertices: Any, chainages: list[Any]) -> list[StructureCandidate]:
"""사용자가 확정한 누가거리 목록을 후보 구조로 되돌린다."""
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import _interpolate_vertex
candidates: list[StructureCandidate] = []
for value in chainages:
try:
chainage = float(value)
except (TypeError, ValueError):
continue
x, y, _ = _interpolate_vertex(vertices, chainage)
candidates.append(StructureCandidate(chainage_m=chainage, x=x, y=y, reason="confirmed"))
return candidates