- 한몸으로 동작하는 두 페이지라 한 커밋으로 처리 (상호 참조 다수) - B05 37파일 + B06 20파일 접두사 개명 (git mv, 이력 보존) - 참조 치환 91파일: import 경로, 라우트 슬러그(b05-profile/b06-section), 라우트 키(B05_PROFILE/B06_SECTION), B03 자동 체인, storage 상수, pyproject 제외 경로 - 로직 변경 없음. typecheck·백엔드 import 검증 통과 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
676 lines
29 KiB
Python
676 lines
29 KiB
Python
"""배수유역 분석 API 라우터 (B04 — 관리자 확인용).
|
|
|
|
계획 노선(B03 업로드 CSV)과 도엽 등고선·세류선으로 배수유역을 끝까지 분석하고, 결과를
|
|
`storage/{프로젝트}/B04_PreProcess/drainage/`에 남긴다. 30초 안팎이 걸리므로 여기서 한 번만
|
|
돌리고, 일반 사용자가 쓰는 B05는 저장분을 읽어 쓴다(2026-07-31 사용자 지시).
|
|
|
|
좌표는 사업지 CRS(m)에서 계산하고 응답만 WGS84로 바꿔 내보낸다.
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import logging
|
|
import math
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
import numpy as np
|
|
from fastapi import APIRouter
|
|
from fastapi.responses import JSONResponse
|
|
from pyproj import Transformer
|
|
from shapely.geometry import Point, Polygon, box
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import preview_stages
|
|
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import (
|
|
drainage_dir,
|
|
write_grid_arrays,
|
|
write_stage,
|
|
)
|
|
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import (
|
|
AZIMUTH_INVALID,
|
|
AZIMUTH_SINK,
|
|
AZIMUTH_STEPS,
|
|
mask_row_spans,
|
|
)
|
|
from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg
|
|
from common_util.common_util_route_geometry import (
|
|
StructureCandidate,
|
|
find_planned_route_file,
|
|
read_planned_route_csv,
|
|
)
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from common_util.common_util_wamis_rainfall import (
|
|
build_rainfall_table,
|
|
ensure_contour_cache,
|
|
)
|
|
from config.config_db import get_db_pool
|
|
from config.config_system import (
|
|
DRAINAGE_ARROW_SPACING_M,
|
|
DRAINAGE_DESIGN_RETURN_PERIOD_YR,
|
|
DRAINAGE_RAINFALL_FILENAME,
|
|
DRAINAGE_RESPONSE_FILENAME,
|
|
WAMIS_CONTOUR_CACHE_DIR,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Watershed"])
|
|
|
|
# 도엽 레이어 파일명 (B04 전처리 산출물과 같은 위치)
|
|
_CONTOUR_FILE = "도엽_등고선.geojson"
|
|
_STREAM_FILE = "도엽_하천중심선.geojson"
|
|
|
|
# 응답 형식 판(版). 응답에 항목을 더하거나 값의 의미를 바꾸면 이 값을 올린다 —
|
|
# 저장해 둔 옛 응답을 그대로 돌려주면 화면이 조용히 어긋나기 때문이다.
|
|
# 2 = 강도 곡선 간격 1m(구간 합) + 유입 집중점(inflow_hotspots) 추가 (2026-08-01)
|
|
# 3 = 강도 곡선 구간 기준을 round→floor로 바로잡고(2m 주기 빗살 제거),
|
|
# 유입 집중점이 기본 관 옆 최소간격 안쪽을 피하도록 수정 (2026-08-01)
|
|
RESPONSE_SCHEMA_VERSION = 3
|
|
|
|
|
|
def _sheet_dir(stored_path: str) -> Path:
|
|
return Path(resolve_stored_project_path(stored_path)) / "B04_PreProcess" / "processed"
|
|
|
|
|
|
def _route_input_dir(stored_path: str) -> Path:
|
|
"""B03 업로드 폴더 — 계획 노선 파일이 여기 들어온다."""
|
|
return Path(resolve_stored_project_path(stored_path)) / "B03_FileInput" / "input"
|
|
|
|
|
|
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:
|
|
"""계획 노선 파일과 도엽 피처, 좌표 변환기를 준비한다.
|
|
|
|
노선은 **B03에 업로드된 계획 노선 파일**에서 읽는다 — B05의 확정 경로가 아니다.
|
|
배수유역 분석은 노선 설계보다 먼저 끝나 있어야 하기 때문이다(2026-07-31 사용자 지시).
|
|
"""
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
epsg = await get_surface_crs_epsg(connection, project_id, 0)
|
|
|
|
route_file = find_planned_route_file(_route_input_dir(stored_path))
|
|
if route_file is None:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "계획 노선 파일이 업로드되지 않았습니다."},
|
|
)
|
|
planned = read_planned_route_csv(route_file)
|
|
if planned is None or len(planned.vertices) < 2:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"status": "error",
|
|
"message": f"계획 노선 파일을 읽지 못했습니다: {route_file.name}",
|
|
},
|
|
)
|
|
|
|
# 노선 파일이 CRS를 명시하면 그 값을 따른다. 도엽 재투영도 같은 좌표계로 맞춘다.
|
|
source_crs = f"EPSG:{planned.epsg or epsg or 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
|
|
)
|
|
return {
|
|
"route_source": route_file.name,
|
|
"vertices": planned.vertices,
|
|
"route_line": planned.line,
|
|
"streams": streams,
|
|
"contours": contour_features,
|
|
"stored_path": stored_path,
|
|
"to_lonlat": lambda x, y: to_lonlat_transformer.transform(x, y),
|
|
}
|
|
|
|
|
|
def _response_path(stored_path: str) -> Path:
|
|
"""분석 응답 캐시 경로. 재산정하지 않는 한 이 파일을 그대로 돌려준다."""
|
|
return drainage_dir(stored_path) / DRAINAGE_RESPONSE_FILENAME
|
|
|
|
|
|
def _load_saved_response(stored_path: str) -> dict[str, Any] | None:
|
|
path = _response_path(stored_path)
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
with path.open("r", encoding="utf-8") as file:
|
|
saved = json.load(file)
|
|
except (OSError, json.JSONDecodeError):
|
|
logger.warning("배수유역: 저장된 분석 응답을 읽지 못했습니다 (%s).", path)
|
|
return None
|
|
# 응답 형식이 바뀌면 옛 저장분을 그대로 주면 안 된다 — 화면이 없는 항목을 그리려다
|
|
# 조용히 어긋난다(예: 강도 곡선 간격 5m→1m, 유입 집중점 신설). 다시 계산하게 둔다.
|
|
if saved.get("schema_version") != RESPONSE_SCHEMA_VERSION:
|
|
logger.info("배수유역: 저장분이 옛 형식이라 다시 분석합니다 (%s).", path)
|
|
return None
|
|
return saved
|
|
|
|
|
|
def _save_response(stored_path: str, payload: dict[str, Any]) -> None:
|
|
path = _response_path(stored_path)
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("w", encoding="utf-8") as file:
|
|
json.dump(payload, file, ensure_ascii=False)
|
|
except OSError:
|
|
logger.warning("배수유역: 분석 응답을 저장하지 못했습니다 (%s).", path)
|
|
|
|
|
|
def _rainfall_path(stored_path: str) -> Path:
|
|
return drainage_dir(stored_path) / DRAINAGE_RAINFALL_FILENAME
|
|
|
|
|
|
def _route_center_lonlat(stored_path: str, fallback_epsg: int | None) -> tuple[float, float] | None:
|
|
"""계획 노선 중간점(WGS84). 강우량 내삽 기준 좌표 — 유역 규모 대비 등우선 간격이
|
|
훨씬 넓어 노선 대표점 하나로 고정한다(설계기준.md 4절, 2026-08-05 협의)."""
|
|
route_file = find_planned_route_file(_route_input_dir(stored_path))
|
|
if route_file is None:
|
|
return None
|
|
planned = read_planned_route_csv(route_file)
|
|
if planned is None or not planned.vertices:
|
|
return None
|
|
middle = planned.vertices[len(planned.vertices) // 2]
|
|
source_crs = f"EPSG:{planned.epsg or fallback_epsg or 5186}"
|
|
lon, lat = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True).transform(
|
|
middle.x, middle.y
|
|
)
|
|
return lat, lon
|
|
|
|
|
|
def _build_rainfall_sync(lat: float, lon: float) -> dict[str, Any]:
|
|
"""등우선 전역 캐시 확보(없는 파일만 다운로드) 후 지점 강우량표를 만든다."""
|
|
cached, failures = ensure_contour_cache(WAMIS_CONTOUR_CACHE_DIR)
|
|
table = build_rainfall_table(
|
|
lat,
|
|
lon,
|
|
WAMIS_CONTOUR_CACHE_DIR,
|
|
design_return_period=DRAINAGE_DESIGN_RETURN_PERIOD_YR,
|
|
)
|
|
table["contour_cache_files"] = cached
|
|
if failures:
|
|
table["failures"] = (table.get("failures") or []) + failures
|
|
return table
|
|
|
|
|
|
async def _ensure_rainfall_table(stored_path: str, fallback_epsg: int | None) -> None:
|
|
"""rainfall_table.json이 없으면 백그라운드로 만든다. 실패는 비치명(로그만)."""
|
|
path = _rainfall_path(stored_path)
|
|
if path.exists():
|
|
return
|
|
center = _route_center_lonlat(stored_path, fallback_epsg)
|
|
if center is None:
|
|
return
|
|
try:
|
|
table = await asyncio.to_thread(_build_rainfall_sync, *center)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(table, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
logger.info("확률강우량표 저장: %s (값 %d개)", path, len(table.get("values") or []))
|
|
except Exception: # noqa: BLE001 — 외부망 차단 등. 배수유역 해석은 계속되어야 한다.
|
|
logger.warning("확률강우량표 생성 실패 (%s)", stored_path, exc_info=True)
|
|
|
|
|
|
@router.get("/{project_id}/drainage/rainfall", response_model=None)
|
|
async def get_drainage_rainfall(
|
|
project_id: UUID, refresh: bool = False
|
|
) -> dict[str, Any] | JSONResponse:
|
|
"""프로젝트 지점의 확률강우량표(설계빈도 IDF 적합계수 포함)를 돌려준다.
|
|
|
|
저장분이 있으면 그대로 주고, 없으면 즉석 생성한다(최초 1회는 등우선 96개
|
|
다운로드로 수십 초 걸릴 수 있다). B05 세션 캐시가 이 응답을 물고 다닌다.
|
|
"""
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
epsg = await get_surface_crs_epsg(connection, project_id, 0)
|
|
path = _rainfall_path(stored_path)
|
|
if not refresh and path.exists():
|
|
try:
|
|
return {**json.loads(path.read_text(encoding="utf-8")), "from_cache": True}
|
|
except (OSError, json.JSONDecodeError):
|
|
logger.warning("강우량표 저장분을 읽지 못해 다시 만듭니다 (%s).", path)
|
|
center = _route_center_lonlat(stored_path, epsg)
|
|
if center is None:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "계획 노선 파일이 업로드되지 않았습니다."},
|
|
)
|
|
try:
|
|
table = await asyncio.to_thread(_build_rainfall_sync, *center)
|
|
except Exception as exc: # noqa: BLE001
|
|
return JSONResponse(
|
|
status_code=502,
|
|
content={
|
|
"status": "error",
|
|
"message": f"확률강우량 자료를 받지 못했습니다: {exc}",
|
|
},
|
|
)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(table, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
return {**table, "from_cache": False}
|
|
|
|
|
|
@router.get("/{project_id}/drainage/primary-region", response_model=None)
|
|
async def get_primary_region(
|
|
project_id: UUID, refresh: bool = False
|
|
) -> dict[str, Any] | JSONResponse:
|
|
"""배수유역 분석 결과를 돌려준다.
|
|
|
|
기본은 **영구저장소에 남은 결과를 그대로** 준다 — 분석이 30초 걸리므로 화면을 열
|
|
때마다 다시 돌릴 이유가 없다. `refresh=true`면 처음부터 다시 계산하고 덮어쓴다
|
|
(2026-07-31 사용자 지시).
|
|
"""
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
epsg = await get_surface_crs_epsg(connection, project_id, 0)
|
|
# 확률강우량표가 없으면 백그라운드로 만들어 둔다 — 유효직경 계산(B05)이 이 파일을 쓴다.
|
|
asyncio.create_task(_ensure_rainfall_table(stored_path, epsg))
|
|
if not refresh:
|
|
saved = _load_saved_response(stored_path)
|
|
if saved is not None:
|
|
logger.info("배수유역: 저장된 분석 결과를 그대로 돌려줍니다 (%s).", stored_path)
|
|
return {**saved, "from_cache": True}
|
|
|
|
prepared = await _prepare(project_id)
|
|
if isinstance(prepared, JSONResponse):
|
|
return prepared
|
|
preview = await asyncio.to_thread(
|
|
preview_stages,
|
|
prepared["vertices"],
|
|
prepared["contours"],
|
|
prepared["streams"],
|
|
)
|
|
if preview is None:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"status": "error", "message": "1차 배수유역을 정할 등고선이 없습니다."},
|
|
)
|
|
region = preview.region
|
|
to_lonlat = prepared["to_lonlat"]
|
|
# 확장을 거치면 격자·해석 영역이 1차 영역보다 커진다. 최종본을 써야 화면과 어긋나지 않는다.
|
|
spec = preview.spec or region.spec
|
|
domain = preview.domain if preview.domain is not None else region.cell_mask
|
|
payload = {
|
|
"status": "success",
|
|
"schema_version": RESPONSE_SCHEMA_VERSION,
|
|
"project_id": str(project_id),
|
|
"route_source": prepared["route_source"],
|
|
"radius_m": region.radius_m,
|
|
# 채택된 상류 세류망 = 1차 영역의 기준선.
|
|
"upstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.upstream],
|
|
# 도로 아래로 이어진 하류망 — 판정이 맞는지 눈으로 대조하기 위해 함께 준다.
|
|
"downstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.downstream],
|
|
"no_contact_count": region.split.no_contact,
|
|
# 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호.
|
|
"road_outside_m": round(region.road_outside_m, 1),
|
|
# 1차 영역(버퍼 합집합) 외곽 링 목록.
|
|
"region_rings": _polygon_rings(region.area, to_lonlat),
|
|
"grid": {
|
|
"cell_m": spec.cell_m,
|
|
"rows": spec.n_rows,
|
|
"cols": spec.n_cols,
|
|
# bbox 전체 셀 수와, 해석 영역에 실제로 생성된 셀 수(확장 반영).
|
|
"bbox_cells": spec.size,
|
|
"cells": int(domain.sum()) if domain is not None else 0,
|
|
"width_m": round(spec.n_cols * spec.cell_m, 1),
|
|
"height_m": round(spec.n_rows * spec.cell_m, 1),
|
|
# 격자 bbox 링. 프론트는 이 사각형을 rows×cols로 나눠 행·열 좌표를 얻는다.
|
|
"bbox_lonlat": _grid_bbox_lonlat(spec, to_lonlat),
|
|
# 실제 생성된 셀을 행별 연속 구간 [행, 시작열, 끝열]으로 압축해 보낸다.
|
|
# 셀을 낱개로 보내면 수십만 건이라 응답이 감당되지 않는다.
|
|
"row_spans": [list(span) for span in mask_row_spans(domain)]
|
|
if domain is not None
|
|
else [],
|
|
},
|
|
# 최외곽 적색 셀 주변 확장 결과.
|
|
"expansion": {
|
|
"rounds": preview.expand_rounds,
|
|
"closed": preview.expand_closed,
|
|
"added_cells": preview.expand_added_cells,
|
|
"initial_cells": region.active_cells,
|
|
},
|
|
# 셀별 흐름 방향과 도로 도달 여부. row_spans 순서(행 → 구간 → 열 오름차순)로 1바이트씩.
|
|
"flow": _flow_payload(preview, domain),
|
|
# ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 면적.
|
|
"basin_polygon_lonlat": [list(to_lonlat(x, y)) for x, y in preview.basin_boundary_xy],
|
|
"basin_area_m2": round(preview.basin_area_m2, 1),
|
|
# ⑥ 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡].
|
|
"strength_profile": [
|
|
[round(chainage, 1), round(area, 1)] for chainage, area in preview.strength_profile
|
|
],
|
|
# ⑥-1 유입 집중점 — 기본 관으로 나눈 구역마다 물이 많이 모이는 자리.
|
|
# [누가거리 m, 유입면적 ㎡, 구역 번호, 구역 내 순위]. 화면 마커·하이라이트 대상이다.
|
|
"inflow_hotspots": [
|
|
[round(chainage, 1), round(area, 1), zone, rank]
|
|
for chainage, area, zone, rank in preview.inflow_hotspots
|
|
],
|
|
# ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점.
|
|
"pipes": [_candidate_payload(pipe, to_lonlat) for pipe in preview.pipes],
|
|
# B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수].
|
|
# 세류·도로 셀을 뺀 블록 평균이라 사면 경향만 남는다.
|
|
"flow_arrows": [
|
|
[*to_lonlat(x, y), round(math.degrees(angle), 1), reaches, cells]
|
|
for x, y, angle, reaches, cells in preview.flow_arrows
|
|
],
|
|
# 화살표 간격(m). 화면이 화살표 크기를 정할 때 쓴다 — 서로 닿지 않게 이 값보다 짧게 그린다.
|
|
"arrow_spacing_m": DRAINAGE_ARROW_SPACING_M,
|
|
"from_cache": False,
|
|
}
|
|
# 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다.
|
|
payload["saved_to"] = write_stage(
|
|
prepared["stored_path"],
|
|
"primary_region",
|
|
{
|
|
"primary_region": _as_polygons(region.area),
|
|
"upstream": region.split.upstream,
|
|
"downstream": region.split.downstream,
|
|
"route": [prepared["route_line"]],
|
|
"grid_bbox": [_grid_bbox_polygon(spec)],
|
|
# ⑦ 2차 전체 배수유역 외곽선, ⑧ 기본 관 위치(누가거리·근거 포함).
|
|
"basin_boundary": _boundary_geometry(preview.basin_boundary_xy),
|
|
"pipe": [
|
|
(
|
|
Point(pipe.x, pipe.y),
|
|
{
|
|
"chainage_m": round(pipe.chainage_m, 2),
|
|
"reason": pipe.reason,
|
|
"stream_name": pipe.stream_name,
|
|
},
|
|
)
|
|
for pipe in preview.pipes
|
|
],
|
|
},
|
|
{
|
|
"radius_m": region.radius_m,
|
|
"road_outside_m": payload["road_outside_m"],
|
|
"no_contact_count": region.split.no_contact,
|
|
"basin_area_m2": payload["basin_area_m2"],
|
|
"pipe_count": len(preview.pipes),
|
|
# 기하(bbox·셀 구간)는 파일 본문에 있으므로 요약 수치만 남긴다.
|
|
"grid": {
|
|
key: value
|
|
for key, value in payload["grid"].items()
|
|
if key not in {"bbox_lonlat", "row_spans"}
|
|
},
|
|
},
|
|
to_lonlat,
|
|
)
|
|
_write_stage_arrays(prepared["stored_path"], preview, domain, spec)
|
|
_write_road_routing(prepared["stored_path"], preview, spec, prepared["route_line"], to_lonlat)
|
|
# 응답 자체를 캐시로 남긴다 — 다음 조회는 배열을 재조립하지 않고 이 파일을 그대로 준다.
|
|
_save_response(prepared["stored_path"], payload)
|
|
return payload
|
|
|
|
|
|
def _write_road_routing(
|
|
stored_path: str, preview: Any, spec: Any, route_line: Any, to_lonlat: Any
|
|
) -> None:
|
|
"""B05가 세부유역을 나눌 때 쓸 최소 산출물을 남긴다.
|
|
|
|
B05는 일반 사용자용이라 가벼워야 한다. 화살표(방향 코드)·밴드 표고 같은 확인용 배열은
|
|
빼고, **셀 → 도로 셀 귀속**과 도로 셀 제원만 담는다. 여기에 표고를 함께 넣는 이유는
|
|
유역 낙차를 내려면 셀 표고가 필요해서다(2026-07-31 사용자 지시).
|
|
"""
|
|
routing = preview.routing
|
|
road = preview.road
|
|
if routing is None or road is None or road.count == 0:
|
|
return
|
|
write_grid_arrays(
|
|
stored_path,
|
|
"road_routing",
|
|
spec,
|
|
{
|
|
"road_slot": routing.road_slot,
|
|
"path_length": routing.path_length,
|
|
"strength": routing.strength,
|
|
"road_cell_index": road.cell_index,
|
|
"road_chainage": road.chainage,
|
|
"elevation": preview.terrain.elevation.reshape(-1),
|
|
},
|
|
{
|
|
"road_cells": road.count,
|
|
"reached_cells": int((routing.road_slot >= 0).sum()),
|
|
"basin_area_m2": round(preview.basin_area_m2, 1),
|
|
"pipe_count": len(preview.pipes),
|
|
},
|
|
)
|
|
# B05가 그대로 그릴 기하 — 계획도로선 · 기본 배관 · 2차 전체 배수유역, 이 셋뿐이다.
|
|
write_stage(
|
|
stored_path,
|
|
"road_routing",
|
|
{
|
|
"route": [route_line],
|
|
"basin_boundary": _boundary_geometry(preview.basin_boundary_xy),
|
|
"pipe": [
|
|
(
|
|
Point(pipe.x, pipe.y),
|
|
{"chainage_m": round(pipe.chainage_m, 2), "reason": pipe.reason},
|
|
)
|
|
for pipe in preview.pipes
|
|
],
|
|
# 평균 흐름 화살표 — B05도 같은 그림을 그려야 하므로 여기 함께 남긴다.
|
|
"flow_arrow": [
|
|
(
|
|
Point(x, y),
|
|
{
|
|
# B05 화면은 사업지 CRS(m)로 그리므로 미터 좌표도 함께 남긴다.
|
|
"x": round(x, 2),
|
|
"y": round(y, 2),
|
|
"azimuth_deg": round(math.degrees(angle), 1),
|
|
"reaches_road": reaches,
|
|
"cells": cells,
|
|
},
|
|
)
|
|
for x, y, angle, reaches, cells in preview.flow_arrows
|
|
],
|
|
},
|
|
{
|
|
"basin_area_m2": round(preview.basin_area_m2, 1),
|
|
"pipe_count": len(preview.pipes),
|
|
"route_length_m": round(route_line.length, 1),
|
|
"arrow_count": len(preview.flow_arrows),
|
|
"arrow_spacing_m": DRAINAGE_ARROW_SPACING_M,
|
|
},
|
|
to_lonlat,
|
|
)
|
|
|
|
|
|
def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) -> None:
|
|
"""격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다."""
|
|
if domain is not None:
|
|
write_grid_arrays(
|
|
stored_path,
|
|
"primary_region",
|
|
spec,
|
|
{"mask": domain},
|
|
{
|
|
"cells": int(domain.sum()),
|
|
"bbox_cells": spec.size,
|
|
"expand_rounds": preview.expand_rounds,
|
|
"expand_closed": preview.expand_closed,
|
|
},
|
|
)
|
|
flow = preview.flow
|
|
if flow is None:
|
|
return
|
|
arrays = {
|
|
"direction": flow.direction.reshape(spec.n_rows, spec.n_cols),
|
|
"reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols),
|
|
"analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols),
|
|
# 사후 진단용 — 수신 셀이 있어야 사슬을 다시 따라가 볼 수 있다.
|
|
"receiver": preview.terrain.receiver.reshape(spec.n_rows, spec.n_cols),
|
|
}
|
|
if flow.burned is not None:
|
|
arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols)
|
|
if preview.descent is not None:
|
|
arrays["band_elevation"] = preview.descent.band_elevation
|
|
# ⑥ 흐름 강도 곡선 — 기하가 아니라 수치 곡선이라 GeoJSON이 아닌 여기에 함께 담는다.
|
|
if preview.strength_profile:
|
|
curve = np.asarray(preview.strength_profile, dtype=np.float64)
|
|
arrays["strength_chainage_m"] = curve[:, 0]
|
|
arrays["strength_area_m2"] = curve[:, 1]
|
|
write_grid_arrays(
|
|
stored_path,
|
|
"flow_direction",
|
|
spec,
|
|
arrays,
|
|
{
|
|
"azimuth_steps": AZIMUTH_STEPS,
|
|
"sink_code": AZIMUTH_SINK,
|
|
"invalid_code": AZIMUTH_INVALID,
|
|
"analyzed": int(flow.analyzed.sum()),
|
|
"reaches_road": int((flow.reaches_road & flow.analyzed).sum()),
|
|
"no_road": int((~flow.reaches_road & flow.analyzed).sum()),
|
|
"burned": 0 if flow.burned is None else int(flow.burned.sum()),
|
|
"outer_seeds": flow.outer_seeds,
|
|
"interior_seeds": flow.interior_seeds,
|
|
"strength_points": len(preview.strength_profile),
|
|
"strength_total_m2": round(sum(area for _, area in preview.strength_profile), 1),
|
|
},
|
|
)
|
|
|
|
|
|
def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None:
|
|
"""셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다.
|
|
|
|
셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다:
|
|
하위 6비트(0x3F) = 32방위 코드(0~31, 0=화면 오른쪽·시계방향), 32=제자리, 33=표고 없음
|
|
최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(백색 화살표).
|
|
바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다.
|
|
"""
|
|
flow = preview.flow
|
|
if flow is None or domain is None:
|
|
return None
|
|
order = np.flatnonzero(domain.reshape(-1))
|
|
analyzed = flow.analyzed[order]
|
|
reaches = flow.reaches_road[order]
|
|
packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8)
|
|
packed |= np.where(reaches, 0x80, 0).astype(np.uint8)
|
|
burned = flow.burned
|
|
return {
|
|
"encoding": "base64-uint8",
|
|
"azimuth_steps": AZIMUTH_STEPS,
|
|
"sink_code": AZIMUTH_SINK,
|
|
"invalid_code": AZIMUTH_INVALID,
|
|
"cells": int(order.size),
|
|
"reaches_road": int((reaches & analyzed).sum()),
|
|
"no_road": int((~reaches & analyzed).sum()),
|
|
# 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀.
|
|
"unanalyzed": int((~analyzed).sum()),
|
|
# 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수.
|
|
"burned": 0 if burned is None else int(burned[order].sum()),
|
|
"outer_seeds": flow.outer_seeds,
|
|
"interior_seeds": flow.interior_seeds,
|
|
"data": base64.b64encode(packed.tobytes()).decode("ascii"),
|
|
}
|
|
|
|
|
|
def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]:
|
|
"""2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다)."""
|
|
return [Polygon(ring)] if len(ring) >= 4 else []
|
|
|
|
|
|
def _as_polygons(geometry: Any) -> list[Any]:
|
|
if geometry is None or geometry.is_empty:
|
|
return []
|
|
return list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry]
|
|
|
|
|
|
def _grid_bbox_polygon(spec: Any) -> Polygon:
|
|
x_max = spec.x_min + spec.n_cols * spec.cell_m
|
|
y_min = spec.y_max - spec.n_rows * spec.cell_m
|
|
return box(spec.x_min, y_min, x_max, spec.y_max)
|
|
|
|
|
|
def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]:
|
|
return [list(to_lonlat(x, y)) for x, y in line.coords]
|
|
|
|
|
|
def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]:
|
|
"""폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다."""
|
|
if geometry is None or geometry.is_empty:
|
|
return []
|
|
parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry]
|
|
return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts]
|
|
|
|
|
|
def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]:
|
|
x_min = spec.x_min
|
|
x_max = spec.x_min + spec.n_cols * spec.cell_m
|
|
y_max = spec.y_max
|
|
y_min = spec.y_max - spec.n_rows * spec.cell_m
|
|
corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min))
|
|
return [list(to_lonlat(x, y)) for x, y in corners]
|