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:
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user