- 폴더·내부 파일 51개 접두사 개명 (git mv, 이력 보존) - 저장소 전체 참조 치환 67파일: import 경로, 라우트 슬러그(b04-preprocess), 라우트 키(B04_PREPROCESS), storage 경로 상수, locale, SQL 주석 - 로직 변경 없음 (기계적 치환). typecheck·백엔드 import 검증 통과 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
192 lines
7.5 KiB
Python
192 lines
7.5 KiB
Python
"""배수유역 단계별 검증 산출물을 영구저장소에 남긴다.
|
|
|
|
기능을 하나씩 붙일 때마다 그 단계의 결과를 파일로 남겨 사람이 QGIS 등으로 직접 열어
|
|
대조할 수 있게 하는 것이 목적이다(2026-07-31 사용자 지시). 새 단계를 추가할 때는
|
|
`STAGES`에 이름을 하나 더 넣고 `write_stage()`를 호출하면 된다 — 파일명 규칙과 매니페스트
|
|
갱신은 여기서 일괄로 처리한다.
|
|
|
|
저장 위치: `storage/{회사}/{사용자}/{프로젝트}/B04_PreProcess/drainage/`
|
|
- `{단계번호}_{단계이름}.geojson` — WGS84 FeatureCollection, 피처마다 `kind` 속성
|
|
- `manifest.json` — 지금까지 남긴 단계 목록과 요약값
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from collections.abc import Sequence
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
import numpy as np
|
|
from shapely.geometry.base import BaseGeometry
|
|
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from config.config_system import DRAINAGE_CACHE_DIRNAME
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 단계 이름 → 파일 접두 번호. 순서대로 읽으면 파이프라인 진행 순서가 된다.
|
|
STAGES: dict[str, str] = {
|
|
"primary_region": "01",
|
|
"flow_direction": "02",
|
|
# B05가 읽어 세부유역을 나누는 데 필요한 최소 배열·기하. 화살표·표고는 넣지 않는다.
|
|
"road_routing": "03",
|
|
}
|
|
|
|
_MANIFEST_FILENAME = "manifest.json"
|
|
LonLat = Callable[[float, float], tuple[float, float]]
|
|
|
|
|
|
def drainage_dir(stored_path: str) -> Path:
|
|
return (
|
|
Path(resolve_stored_project_path(stored_path)) / "B04_PreProcess" / DRAINAGE_CACHE_DIRNAME
|
|
)
|
|
|
|
|
|
def write_stage(
|
|
stored_path: str,
|
|
stage: str,
|
|
layers: dict[str, Sequence[BaseGeometry | tuple[BaseGeometry, dict[str, Any]]]],
|
|
properties: dict[str, Any],
|
|
to_lonlat: LonLat,
|
|
) -> str | None:
|
|
"""한 단계의 기하 산출물을 GeoJSON으로 저장하고 매니페스트를 갱신한다.
|
|
|
|
`layers`는 {레이어이름: 사업지 CRS(m) 기하 목록}이며 레이어 이름이 피처 `kind` 속성이
|
|
된다. 기하 대신 `(기하, 속성dict)` 짝을 넣으면 그 속성이 피처에 함께 실린다.
|
|
좌표는 여기서 WGS84로 바꾼다 — 저장 파일은 어떤 도구로 열어도 바로 보여야 한다.
|
|
"""
|
|
prefix = STAGES.get(stage)
|
|
if prefix is None:
|
|
logger.warning("배수유역: 등록되지 않은 저장 단계 '%s' — 저장을 건너뜁니다.", stage)
|
|
return None
|
|
|
|
features: list[dict[str, Any]] = []
|
|
counts: dict[str, int] = {}
|
|
for kind, entries in layers.items():
|
|
for index, entry in enumerate(entries):
|
|
# 항목은 기하 하나이거나 (기하, 속성) 짝이다 — 관 누가거리처럼 붙일 값이 있을 때 쓴다.
|
|
geometry, extra = entry if isinstance(entry, tuple) else (entry, None)
|
|
feature = _to_feature(kind, index, geometry, to_lonlat, extra)
|
|
if feature is not None:
|
|
features.append(feature)
|
|
counts[kind] = len(entries)
|
|
|
|
filename = f"{prefix}_{stage}.geojson"
|
|
directory = drainage_dir(stored_path)
|
|
target = directory / filename
|
|
document = {
|
|
"type": "FeatureCollection",
|
|
"crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}},
|
|
"properties": {**properties, "counts": counts},
|
|
"features": features,
|
|
}
|
|
try:
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
with target.open("w", encoding="utf-8") as file:
|
|
json.dump(document, file, ensure_ascii=False)
|
|
except OSError:
|
|
logger.warning("배수유역: %s 저장 실패 (%s)", stage, target)
|
|
return None
|
|
|
|
_update_manifest(directory, stage, filename, {**properties, "counts": counts})
|
|
logger.info("배수유역: %s 저장 — %s (피처 %d개)", stage, target, len(features))
|
|
return str(target)
|
|
|
|
|
|
def _to_feature(
|
|
kind: str,
|
|
index: int,
|
|
geometry: BaseGeometry,
|
|
to_lonlat: LonLat,
|
|
extra: dict[str, Any] | None = None,
|
|
) -> dict[str, Any] | None:
|
|
coordinates = _to_lonlat_coords(geometry, to_lonlat)
|
|
if coordinates is None:
|
|
return None
|
|
return {
|
|
"type": "Feature",
|
|
"properties": {"kind": kind, "index": index, **(extra or {})},
|
|
"geometry": {"type": geometry.geom_type, "coordinates": coordinates},
|
|
}
|
|
|
|
|
|
def _to_lonlat_coords(geometry: BaseGeometry, to_lonlat: LonLat) -> Any:
|
|
"""shapely 기하를 WGS84 GeoJSON 좌표 배열로 바꾼다."""
|
|
if geometry.is_empty:
|
|
return None
|
|
kind = geometry.geom_type
|
|
if kind == "Point":
|
|
return list(to_lonlat(geometry.x, geometry.y))
|
|
if kind == "LineString":
|
|
return [list(to_lonlat(x, y)) for x, y in geometry.coords]
|
|
if kind == "Polygon":
|
|
return [
|
|
[list(to_lonlat(x, y)) for x, y in ring.coords]
|
|
for ring in (geometry.exterior, *geometry.interiors)
|
|
]
|
|
if kind in {"MultiPoint", "MultiLineString", "MultiPolygon", "GeometryCollection"}:
|
|
parts = [_to_lonlat_coords(part, to_lonlat) for part in geometry.geoms]
|
|
return [part for part in parts if part is not None]
|
|
return None
|
|
|
|
|
|
def write_grid_arrays(
|
|
stored_path: str, stage: str, spec: Any, arrays: dict[str, Any], summary: dict[str, Any]
|
|
) -> str | None:
|
|
"""격자 크기의 배열들을 `.npz`로 남긴다(셀 마스크·흐름 방향·도달 여부 등).
|
|
|
|
셀이 수십만 개라 GeoJSON 폴리곤으로는 못 남긴다. 격자 원점·셀 크기와 배열만 저장하면
|
|
어느 셀이 어떤 값이었는지 그대로 복원된다. 요약값은 manifest에도 기록한다.
|
|
"""
|
|
prefix = STAGES.get(stage)
|
|
if prefix is None or not arrays:
|
|
return None
|
|
directory = drainage_dir(stored_path)
|
|
target = directory / f"{prefix}_{stage}.npz"
|
|
try:
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
np.savez_compressed(
|
|
target,
|
|
x_min=spec.x_min,
|
|
y_max=spec.y_max,
|
|
cell_m=spec.cell_m,
|
|
n_rows=spec.n_rows,
|
|
n_cols=spec.n_cols,
|
|
**arrays,
|
|
)
|
|
except OSError:
|
|
logger.warning("배수유역: %s 배열 저장 실패 (%s)", stage, target)
|
|
return None
|
|
_update_manifest(directory, f"{stage}_arrays", target.name, summary)
|
|
logger.info("배수유역: %s 배열 저장 — %s (%s)", stage, target, ", ".join(arrays))
|
|
return str(target)
|
|
|
|
|
|
def _update_manifest(
|
|
directory: Path, stage: str, filename: str, properties: dict[str, Any]
|
|
) -> None:
|
|
"""지금까지 남긴 단계 목록을 한 파일에 모아 둔다 — 무엇이 저장돼 있는지 한눈에 본다."""
|
|
manifest_path = directory / _MANIFEST_FILENAME
|
|
manifest: dict[str, Any] = {}
|
|
if manifest_path.exists():
|
|
try:
|
|
with manifest_path.open("r", encoding="utf-8") as file:
|
|
loaded = json.load(file)
|
|
if isinstance(loaded, dict):
|
|
manifest = loaded
|
|
except (OSError, json.JSONDecodeError):
|
|
logger.warning("배수유역: manifest를 읽지 못해 새로 만듭니다 (%s).", manifest_path)
|
|
manifest[stage] = {
|
|
"file": filename,
|
|
"saved_at": datetime.now().isoformat(timespec="seconds"),
|
|
"properties": properties,
|
|
}
|
|
try:
|
|
with manifest_path.open("w", encoding="utf-8") as file:
|
|
json.dump(manifest, file, ensure_ascii=False, indent=2)
|
|
except OSError:
|
|
logger.warning("배수유역: manifest 저장 실패 (%s).", manifest_path)
|