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