refactor(B04/B05): 배수유역 분석을 B04로 이관, B05는 저장분 소비만

분석이 30초 걸리는데 B05는 일반 사용자 화면이다. 관리자 확인용 B04에서
한 번 돌려 저장하고, B05는 그 결과를 읽어 관 보충과 세부유역만 처리한다
(2026-07-31 사용자 지시).

노선 원천 변경
- B05 확정 경로 -> B03 업로드 계획 노선 파일(CSV). 분석이 노선 설계보다
  먼저 끝나 있어야 하기 때문. 샘플 planned_route_sample_epsg5187.csv 로 검증.
- common_util_route_geometry.py 신설 — RouteVertex/StructureCandidate/누가거리
  보간/세류 교차점/계획 노선 CSV 리더. B04와 B05가 같은 표현을 쓰도록 공용화.
  열 이름은 대소문자·한글 표기를 함께 받는다(B03이 여러 형식 수용 예정).

B04 (관리자 확인용, 신규)
- Engine_Watershed_{Grid,Stream,Descent,Flow,Expand,Export} — B05에서 git mv
- Engine_Watershed_Analyze.py — 1~8단계 오케스트레이션
- Router_Watershed.py — GET /drainage/primary-region
- UI_Watershed.ts — 2D 지도 GIS 레이어 그룹에 "배수유역" 토글 추가.
  격자/화살표/세류망/1차영역/2차유역/기본관을 겹쳐 그린다.
- 저장 위치 B05_wf2_Route/drainage -> B04_wf1_Surface/drainage
- 03_road_routing 단계 추가: B05가 세부유역을 나눌 최소 배열(셀->도로셀 귀속,
  유하장, 강도, 도로셀 제원, 셀 표고) + 계획도로선/기본배관/2차유역 기하

B05 (일반 사용자용, 축소)
- Engine_Drainage_Basin.py — B04 산출물 로더 + 관 보충(9) + 측구 라우팅/세부유역(10,11)
- Engine_Drainage.py 는 관경 산정만 남기고 322 -> 27줄
- Router_Drainage.py 509 -> 142줄. POST /drainage/basins 만 남김
- 화살표·격자·강도 띠 렌더 제거. 계획도로선/기본배관/2차유역만 받는다

삭제
- _legacy_watershed/ 4파일 (능선 행진 방식 원본 보관본)
- Engine_Watershed_Basin.py (B04 Analyze + B05 Drainage_Basin 으로 분할)
- GET /drainage/candidates 와 propose_structure_stations (구방식 후보 제안)

E2E 검증 (실데이터)
  B04 분석 28.2s -> 저장(geojson 11KB + npz 2.6MB)
  B05 로드 + 세부 설계 0.11s   <-- 30초가 0.1초로
  면적 457,404m2 로 B04 2차 유역과 정확히 일치
  관 편집 재산정 0.12s, 관 3개 -> 세부유역 3개, 면적 보존

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 20:57:09 +09:00
co-authored by Claude Fable 5
parent b0747e74b6
commit 8a0d640e0e
38 changed files with 2440 additions and 3560 deletions
+92 -15
View File
@@ -1,6 +1,7 @@
"""B03 파일 입력 FastAPI 라우터."""
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
@@ -25,6 +26,7 @@ from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata
from B03_FileInput.B03_FileInput_Repository import (
create_input_file,
create_upload_session,
get_project_input_readiness,
get_project_storage_relative_path,
get_upload_session,
list_completed_chunk_indexes,
@@ -60,13 +62,69 @@ from config.config_system import (
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B03 File Input"])
_REQUIRED_FILE_TYPES = frozenset({"csv", "prj", "tfw"})
_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"})
def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int:
return max(1, (size_bytes + chunk_size_bytes - 1) // chunk_size_bytes)
def _is_point_cloud_result(result: UploadedFileResult) -> bool:
return result.file_type.lower() in {"las", "laz"}
return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES
def _missing_required_file_types(file_types: set[str]) -> list[str]:
missing = sorted(_REQUIRED_FILE_TYPES - file_types)
if not file_types.intersection(_POINT_CLOUD_FILE_TYPES):
missing.append("las/laz")
return missing
def _require_complete_file_set(file_types: set[str]) -> None:
missing = _missing_required_file_types(file_types)
if missing:
raise ValueError(f"B03 필수 입력 파일이 없습니다: {', '.join(missing)}")
async def _complete_file_input_if_ready(
connection: aiomysql.Connection,
project_id: UUID,
) -> int:
file_types, point_cloud_input_id = await get_project_input_readiness(connection, project_id)
_require_complete_file_set(file_types)
if point_cloud_input_id is None:
raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.")
async with connection.cursor() as cursor:
await complete_stage(cursor, str(project_id), 0)
return point_cloud_input_id
def _write_stage_metadata(
stage_root: Path,
project_id: UUID,
results: list[UploadedFileResult],
) -> None:
metadata_path = stage_root / "metadata.json"
existing_files: list[dict[str, Any]] = []
if metadata_path.exists():
try:
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
existing_files = list(payload.get("files") or [])
except (OSError, TypeError, ValueError):
logger.warning("B03 metadata.json을 읽지 못해 새로 작성합니다: %s", metadata_path)
merged = {
str(item.get("relative_path") or item.get("original_filename")): item
for item in existing_files
}
for result in results:
dumped = result.model_dump()
merged[result.relative_path] = dumped
atomic_write_json(
metadata_path,
{"project_id": str(project_id), "files": list(merged.values())},
)
def _schedule_background_task(coro: Any, *, task_name: str) -> None:
@@ -170,11 +228,31 @@ async def upload_project_files(
"message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.",
},
)
csv_count = sum(Path(filename).suffix.lower() == ".csv" for filename in filenames)
if csv_count != 1:
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": "계획노선 CSV 파일을 정확히 1개 포함해야 합니다.",
},
)
request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames}
missing_required = _missing_required_file_types(request_file_types)
if missing_required:
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": f"B03 필수 입력 파일이 없습니다: {', '.join(missing_required)}",
},
)
pool = get_db_pool()
saved_paths: list[Path] = []
try:
results: list[UploadedFileResult] = []
point_cloud_input_id: int | None = None
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
@@ -219,18 +297,14 @@ async def upload_project_files(
metadata=metadata,
)
)
async with connection.cursor() as cursor:
await complete_stage(cursor, str(project_id), 0)
point_cloud_input_id = await _complete_file_input_if_ready(connection, project_id)
await connection.commit()
except Exception:
await connection.rollback()
raise
stage_root = project_root / "B03_FileInput"
atomic_write_json(
stage_root / "metadata.json",
{"project_id": str(project_id), "files": [result.model_dump() for result in results]},
)
_write_stage_metadata(stage_root, project_id, results)
workflow_path = project_root / "workflow.json"
if not workflow_path.exists():
atomic_write_json(workflow_path, load_project_workflow(project_root))
@@ -246,10 +320,11 @@ async def upload_project_files(
),
task_name=f"b03-upload-email-{project_id}",
)
if point_cloud_input_id is not None:
_schedule_background_task(
trigger_wf1_analysis_and_email(
project_id=project_id,
input_file_id=point_cloud_result.input_file_id,
input_file_id=point_cloud_input_id,
user_role=str(session["role"]),
),
task_name=f"b04-wf1-auto-{project_id}",
@@ -390,6 +465,7 @@ async def finalize_project_upload(
"""청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다."""
pool = get_db_pool()
final_path: Path | None = None
point_cloud_input_id: int | None = None
try:
async with pool.acquire() as connection:
session = await get_upload_session(
@@ -444,8 +520,11 @@ async def finalize_project_upload(
metadata=metadata,
)
await mark_upload_session_completed(connection, session_id=payload.session_id)
async with connection.cursor() as cursor:
await complete_stage(cursor, str(project_id), 0)
if payload.complete_upload:
point_cloud_input_id = await _complete_file_input_if_ready(
connection,
project_id,
)
await connection.commit()
except Exception:
await connection.rollback()
@@ -462,10 +541,7 @@ async def finalize_project_upload(
metadata=metadata,
)
stage_root = project_root / "B03_FileInput"
atomic_write_json(
stage_root / "metadata.json",
{"project_id": str(project_id), "files": [result.model_dump()]},
)
_write_stage_metadata(stage_root, project_id, [result])
if _is_point_cloud_result(result):
_schedule_background_task(
_send_upload_complete_notification(
@@ -474,10 +550,11 @@ async def finalize_project_upload(
),
task_name=f"b03-upload-email-{project_id}",
)
if point_cloud_input_id is not None:
_schedule_background_task(
trigger_wf1_analysis_and_email(
project_id=project_id,
input_file_id=result.input_file_id,
input_file_id=point_cloud_input_id,
user_role=str(session["role"]),
),
task_name=f"b04-wf1-auto-{project_id}",