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
+6 -1
View File
@@ -120,12 +120,17 @@ export async function finalizeUploadSession(
projectId: string,
sessionId: string,
totalChunks: number,
completeUpload: boolean,
): Promise<FileUploadResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, total_chunks: totalChunks }),
body: JSON.stringify({
session_id: sessionId,
total_chunks: totalChunks,
complete_upload: completeUpload,
}),
});
return await readJsonOrThrow<FileUploadResponse>(response);
}
@@ -1,5 +1,6 @@
"""B03 원본 입력 파일 메타데이터 분석."""
import csv
import logging
import math
import re
@@ -269,10 +270,121 @@ def analyze_tif_metadata(path: str | Path) -> dict[str, Any]:
rasterio_logger.removeFilter(warning_filter)
_PLANNED_ROUTE_COLUMNS = ("route_name", "sequence", "x", "y", "z", "crs_epsg")
def _parse_route_integer(value: str, *, field: str, row_number: int) -> int:
normalized = value.strip()
if not re.fullmatch(r"[0-9]+", normalized):
raise ValueError(f"CSV {row_number}행의 {field} 값은 양의 정수여야 합니다.")
parsed = int(normalized)
if parsed <= 0:
raise ValueError(f"CSV {row_number}행의 {field} 값은 양의 정수여야 합니다.")
return parsed
def _parse_route_coordinate(value: str, *, field: str, row_number: int) -> float:
try:
parsed = float(value.strip())
except (AttributeError, ValueError) as exc:
raise ValueError(f"CSV {row_number}행의 {field} 값은 숫자여야 합니다.") from exc
if not math.isfinite(parsed):
raise ValueError(f"CSV {row_number}행의 {field} 값은 유한한 숫자여야 합니다.")
return parsed
def analyze_planned_route_csv(path: str | Path) -> dict[str, Any]:
"""원청 계획노선 CSV를 검증하고 경로 메타데이터를 반환한다."""
source = Path(path)
with source.open("r", encoding="utf-8-sig", newline="") as csv_file:
reader = csv.DictReader(csv_file)
if reader.fieldnames is None:
raise ValueError("계획노선 CSV 헤더를 찾을 수 없습니다.")
normalized_headers = [header.strip() for header in reader.fieldnames]
if len(set(normalized_headers)) != len(normalized_headers):
raise ValueError("계획노선 CSV 헤더에 중복된 열이 있습니다.")
header_map = dict(zip(normalized_headers, reader.fieldnames, strict=True))
missing = [column for column in _PLANNED_ROUTE_COLUMNS if column not in header_map]
if missing:
raise ValueError(f"계획노선 CSV 필수 열이 없습니다: {', '.join(missing)}")
route_name: str | None = None
crs_epsg: int | None = None
points: list[tuple[float, float, float]] = []
for expected_sequence, row in enumerate(reader, start=1):
row_number = expected_sequence + 1
current_name = (row.get(header_map["route_name"]) or "").strip()
if not current_name:
raise ValueError(f"CSV {row_number}행의 route_name 값이 비어 있습니다.")
if route_name is None:
route_name = current_name
elif current_name != route_name:
raise ValueError("계획노선 CSV에는 하나의 route_name만 사용할 수 있습니다.")
sequence = _parse_route_integer(
row.get(header_map["sequence"]) or "",
field="sequence",
row_number=row_number,
)
if sequence != expected_sequence:
raise ValueError(
f"CSV {row_number}행의 sequence는 {expected_sequence}이어야 합니다."
)
current_epsg = _parse_route_integer(
row.get(header_map["crs_epsg"]) or "",
field="crs_epsg",
row_number=row_number,
)
if crs_epsg is None:
crs_epsg = current_epsg
elif current_epsg != crs_epsg:
raise ValueError("계획노선 CSV의 crs_epsg는 모든 행에서 같아야 합니다.")
points.append(
tuple(
_parse_route_coordinate(
row.get(header_map[field]) or "",
field=field,
row_number=row_number,
)
for field in ("x", "y", "z")
)
)
if len(points) < 2:
raise ValueError("계획노선 CSV에는 좌표가 2개 이상 있어야 합니다.")
xs, ys, zs = zip(*points, strict=True)
return {
"file": source.name,
"extension": "csv",
"size_bytes": source.stat().st_size,
"purpose": "planned_route",
"route_name": route_name,
"point_count": len(points),
"epsg": crs_epsg,
"columns": list(_PLANNED_ROUTE_COLUMNS),
"bounds": {
"x_min": min(xs),
"x_max": max(xs),
"y_min": min(ys),
"y_max": max(ys),
"z_min": min(zs),
"z_max": max(zs),
},
"start_point": list(points[0]),
"end_point": list(points[-1]),
}
def analyze_input_metadata(path: str | Path) -> dict[str, Any]:
"""입력 파일 확장자에 맞는 B03 메타데이터 분석 함수를 호출한다."""
source = Path(path)
extension = source.suffix.lower()
if extension == ".csv":
return analyze_planned_route_csv(source)
if extension in {".las", ".laz"}:
return analyze_las_metadata(source)
if extension == ".prj":
+25
View File
@@ -61,6 +61,31 @@ async def create_input_file(
return int(input_file_id)
async def get_project_input_readiness(
connection: aiomysql.Connection,
project_id: UUID,
) -> tuple[set[str], int | None]:
"""현재 업로드 파일 유형과 최신 포인트클라우드 입력 ID를 반환한다."""
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""
SELECT id, LOWER(file_type) AS file_type
FROM input_files
WHERE project_id = %s AND status IN ('UPLOADED', 'PROCESSED')
ORDER BY id DESC
""",
(str(project_id),),
)
rows = await cursor.fetchall()
file_types = {str(row["file_type"]) for row in rows if row.get("file_type")}
point_cloud_id = next(
(int(row["id"]) for row in rows if str(row.get("file_type") or "") in {"las", "laz"}),
None,
)
return file_types, point_cloud_id
async def get_project_storage_relative_path(
connection: aiomysql.Connection,
project_id: UUID,
+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}",
+1
View File
@@ -89,6 +89,7 @@ class UploadFinalizeRequest(BaseModel):
session_id: str = Field(min_length=1, max_length=36)
total_chunks: int = Field(gt=0)
complete_upload: bool = True
class UploadStatusResponse(BaseModel):
+11 -5
View File
@@ -368,6 +368,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
async function uploadOneFile(
projectId: string,
state: FileSlotState,
completeUpload: boolean,
): Promise<UploadedFileResult[]> {
const file = state.file;
if (!file) return [];
@@ -421,7 +422,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
}
}
const response = await finalizeUploadSession(projectId, session, totalChunks);
const response = await finalizeUploadSession(projectId, session, totalChunks, completeUpload);
localStorage.removeItem(storageKey);
saveB03UploadedFile(projectId, {
slot: state.slot,
@@ -470,8 +471,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
pageError.textContent = "";
const uploaded: UploadedFileResult[] = [];
try {
for (const state of targetStates) {
uploaded.push(...(await uploadOneFile(activeProjectId, state)));
for (let index = 0; index < targetStates.length; index += 1) {
const state = targetStates[index];
uploaded.push(
...(await uploadOneFile(activeProjectId, state, index === targetStates.length - 1)),
);
}
renderUploadResults(uploaded);
showToast(L("B03_File_Upload_Success"), "success");
@@ -544,11 +548,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
uploadControlPanel.className = "b03-file__control-panel";
uploadControlPanel.append(subtitle, dropzone, resumeBanner, pageError, uploadButton, resultList);
const filesGroup = createCardGroup("", ["las_laz", "prj", "tfw", "tif"]); // 타이틀 공백으로 전달
const routeGroup = createCardGroup(L("B03_File_Group_Route"), ["csv"]);
routeGroup.classList.add("b03-file__group--route");
const filesGroup = createCardGroup(L("B03_File_Group_Terrain"), ["las_laz", "prj", "tfw", "tif"]);
const cardsContainer = document.createElement("div");
cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel";
cardsContainer.append(filesGroup);
cardsContainer.append(routeGroup, filesGroup);
const workflowState = activeProjectId
? await fetchWorkflowState(activeProjectId).catch(() => undefined)
+9
View File
@@ -136,6 +136,15 @@
gap: var(--spacing-24);
}
.b03-file__group--route .b03-file__group-content {
grid-template-columns: 1fr;
}
.b03-file__group--route .b03-file__card {
border-color: var(--color-royal-amethyst, #3e0079);
background: var(--color-mist-violet, #edecff);
}
/* Wiza 8px radius 카드 */
.b03-file__card {
min-height: 220px;
+8 -1
View File
@@ -1,6 +1,6 @@
import { ui_locales } from "@ui/ui_template_locale";
export type FileSlot = "las_laz" | "prj" | "tfw" | "tif" | "dxf";
export type FileSlot = "csv" | "las_laz" | "prj" | "tfw" | "tif" | "dxf";
export type UploadStatus = "pending" | "uploading" | "completed" | "failed";
export interface SlotConfig {
@@ -35,6 +35,13 @@ export interface StoredUploadSession {
}
const SLOT_CONFIGS: readonly SlotConfig[] = [
{
slot: "csv",
labelKey: "B03_File_Slot_PlannedRoute",
icon: "⌁",
extensions: [".csv"],
isRequired: true,
},
{
slot: "las_laz",
labelKey: "B03_File_Slot_PointCloud",
@@ -0,0 +1,58 @@
import tempfile
import unittest
from pathlib import Path
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_planned_route_csv
class PlannedRouteCsvTest(unittest.TestCase):
def analyze(self, content: str) -> dict:
with tempfile.TemporaryDirectory() as temporary_dir:
path = Path(temporary_dir) / "planned_route.csv"
path.write_text(content, encoding="utf-8")
return analyze_planned_route_csv(path)
def test_valid_route_returns_metadata(self) -> None:
metadata = self.analyze(
"route_name,sequence,x,y,z,crs_epsg\n"
"sample,1,183493.5,489290.335,544.659,5187\n"
"sample,2,183500.0,489300.0,545.0,5187\n"
)
self.assertEqual(metadata["purpose"], "planned_route")
self.assertEqual(metadata["route_name"], "sample")
self.assertEqual(metadata["point_count"], 2)
self.assertEqual(metadata["epsg"], 5187)
self.assertEqual(metadata["start_point"], [183493.5, 489290.335, 544.659])
def test_missing_header_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "필수 열"):
self.analyze("route_name,sequence,x,y,crs_epsg\nsample,1,183493.5,489290.335,5187\n")
def test_non_numeric_coordinate_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "x 값은 숫자"):
self.analyze(
"route_name,sequence,x,y,z,crs_epsg\n"
"sample,1,not-a-number,489290.335,544.659,5187\n"
"sample,2,183500.0,489300.0,545.0,5187\n"
)
def test_non_contiguous_sequence_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "sequence는 2"):
self.analyze(
"route_name,sequence,x,y,z,crs_epsg\n"
"sample,1,183493.5,489290.335,544.659,5187\n"
"sample,3,183500.0,489300.0,545.0,5187\n"
)
def test_mixed_epsg_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "모든 행에서 같아야"):
self.analyze(
"route_name,sequence,x,y,z,crs_epsg\n"
"sample,1,183493.5,489290.335,544.659,5187\n"
"sample,2,183500.0,489300.0,545.0,5186\n"
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,60 @@
import json
import tempfile
import unittest
from pathlib import Path
from uuid import UUID
from B03_FileInput.B03_FileInput_Router import (
_missing_required_file_types,
_write_stage_metadata,
)
from B03_FileInput.B03_FileInput_Schema import UploadedFileResult
class B03RouterHelperTest(unittest.TestCase):
def test_required_file_types_include_planned_route(self) -> None:
self.assertEqual(
_missing_required_file_types({"las", "prj", "tfw"}),
["csv"],
)
self.assertEqual(
_missing_required_file_types({"csv", "laz", "prj", "tfw"}),
[],
)
def test_stage_metadata_preserves_existing_files(self) -> None:
project_id = UUID("acb9170b-9ac8-49b3-82a0-51cfa32bb42d")
with tempfile.TemporaryDirectory() as temporary_dir:
stage_root = Path(temporary_dir)
(stage_root / "metadata.json").write_text(
json.dumps(
{
"project_id": str(project_id),
"files": [
{
"original_filename": "terrain.las",
"relative_path": "B03_FileInput/input/las/terrain.las",
}
],
}
),
encoding="utf-8",
)
route = UploadedFileResult(
input_file_id=100,
original_filename="planned_route.csv",
file_type="csv",
relative_path="B03_FileInput/input/csv/planned_route.csv",
size_bytes=1000,
metadata={"purpose": "planned_route", "epsg": 5187},
)
_write_stage_metadata(stage_root, project_id, [route])
payload = json.loads((stage_root / "metadata.json").read_text(encoding="utf-8"))
self.assertEqual(len(payload["files"]), 2)
self.assertEqual(payload["files"][1]["metadata"]["purpose"], "planned_route")
if __name__ == "__main__":
unittest.main()