246 lines
9.5 KiB
Python
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
|