- B05 지형 로더 models_dir · cache_dir=None · 비용면 cache_dir=None · 탐색 복도 corridor_m(기본 100 m · 기본이면 묶음 열쇠 그대로) - D03_PlanAnalysis_Terrain — 지점 + 1 km 표본 3×3 도엽 · prune 없음 · 병합 옛 ∪ 새 · 위성 합집합 · 연도별 models_<연도> DEM · plan.prj - D03_PlanAnalysis_Analyze — 연도 구간마다 갈래 넷 · 법정 기준 · 누가 이어 셈 · 파생 저장본(입력 지문) - D03_PlanAnalysis_Router — POST plan/terrain · plan/analyze(뒤에서 · 진행률) · GET plan/progress · plan/analysis · PUT [저장] - 지킴 시험 test_530_1_plan_terrain · test_530_2_plan_analyze · test_530_2_plan_router Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
222 lines
9.0 KiB
Python
222 lines
9.0 KiB
Python
"""D 예정노선 지형 받기 — 지점 기준 수치지형도 · 위성 · 연도 구간별 DEM (요구 345 · PLAN 530-1).
|
||
|
||
지형은 D 프로젝트 storage 안 `B04_PreProcess/` 배치 그대로(지형 코드 주인 = B04 · 고칠 곳 0):
|
||
|
||
- 도엽 = 모든 지점 + 구간 표본(`SAMPLE_STEP_M`) 각 3×3 → `ensure_sheets`(없는 것만 · prune 없음)
|
||
- 병합 = 지난번 병합 도엽 ∪ 이번 도엽(옛 도엽 유지 · 열쇠 같으면 건너뜀)
|
||
- 위성 = 기준 도엽 도곽 ∪ 옛 위성 범위(덮으면 건너뜀)
|
||
- DEM = 연도 구간마다 `models_<연도>/`(긴 노선 메모리 · 비용면 한도) · 범위 = 구간 상자 + 탐색 복도
|
||
- 작업 좌표계 = 지점 epsg · `B03_FileInput/input/prj/plan.prj` 한 장(B04 · B05 좌표계 창구가 읽음)
|
||
|
||
계산 자리 = ③ 서버 단독. DB 행(`surface_models`)은 라우터가 씀.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import math
|
||
from pathlib import Path
|
||
from typing import Any, Callable
|
||
|
||
import numpy as np
|
||
|
||
from B04_PreProcess.B04_PreProcess_Engine_MapSheet import (
|
||
neighbors_for_points,
|
||
sheet5k_to_bounds,
|
||
sheets_for_points,
|
||
)
|
||
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import parse_and_merge_sheets
|
||
from B04_PreProcess.B04_PreProcess_Engine_SheetStore import (
|
||
ensure_sheets,
|
||
get_project_map_sheets_dir,
|
||
get_sheet_path,
|
||
)
|
||
from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import build_sheet_surface_model
|
||
from B04_PreProcess.B04_PreProcess_Engine_VWorld import download_vworld_satellite_map
|
||
from common_util.common_util_json import atomic_write_json
|
||
from config.config_system_terrain import SHEET_SURFACE_DEFAULT_METHOD, SHEET_SURFACE_MARGIN_M
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 구간 표본 간격 — 도엽(≈2.8 × 2.3 km) 3×3 묶음이 끊기지 않게
|
||
SAMPLE_STEP_M = 1000.0
|
||
# 탐색 복도 반폭(D 만 · B 기본 100 m) — 실측(530-2): 노선이 복도 끝에 닿지 않는 폭
|
||
PLAN_CORRIDOR_M = 300.0
|
||
# DEM 이 복도 밖으로 더 덮는 폭(복도 끝 칸 기울기 · 횡단 표본 ±20 m)
|
||
DEM_EXTRA_M = 50.0
|
||
STAGE = Path("B04_PreProcess")
|
||
PRJ_NAME = "plan.prj"
|
||
|
||
Progress = Callable[[int, str], None]
|
||
|
||
|
||
def models_dir_of(project_root: Path, year: int) -> Path:
|
||
"""연도 구간 DEM 자리."""
|
||
return project_root / STAGE / f"models_{int(year)}"
|
||
|
||
|
||
def segment_samples(
|
||
start: tuple[float, float], end: tuple[float, float]
|
||
) -> list[tuple[float, float]]:
|
||
"""구간 위 표본(끝점 둘 포함 · `SAMPLE_STEP_M` 간격 이하)."""
|
||
length = math.hypot(end[0] - start[0], end[1] - start[1])
|
||
count = max(1, math.ceil(length / SAMPLE_STEP_M))
|
||
return [
|
||
(start[0] + (end[0] - start[0]) * i / count, start[1] + (end[1] - start[1]) * i / count)
|
||
for i in range(count + 1)
|
||
]
|
||
|
||
|
||
def reference_points(segments: list[dict[str, Any]], epsg: int) -> list[tuple[float, float]]:
|
||
"""도엽 기준 좌표(위도, 경도) — 모든 지점 + 구간 표본."""
|
||
from pyproj import Transformer
|
||
|
||
to_wgs = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True)
|
||
points: list[tuple[float, float]] = []
|
||
for seg in segments:
|
||
for x, y in segment_samples(seg["start"], seg["end"]):
|
||
lon, lat = to_wgs.transform(x, y)
|
||
points.append((lat, lon))
|
||
return points
|
||
|
||
|
||
def write_plan_prj(project_root: Path, epsg: int) -> Path:
|
||
"""작업 좌표계 .prj — 내용이 같으면 그대로."""
|
||
from pyproj import CRS
|
||
|
||
path = project_root / "B03_FileInput" / "input" / "prj" / PRJ_NAME
|
||
text = CRS.from_epsg(int(epsg)).to_wkt("WKT1_ESRI")
|
||
if not path.is_file() or path.read_text(encoding="utf-8", errors="ignore") != text:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(text, encoding="utf-8")
|
||
return path
|
||
|
||
|
||
def dem_outline(start: tuple[float, float], end: tuple[float, float]) -> np.ndarray:
|
||
"""DEM 상자 꼭짓점 둘 — B 여유(`SHEET_SURFACE_MARGIN_M`)를 더하면 복도 + `DEM_EXTRA_M`."""
|
||
pad = max(0.0, PLAN_CORRIDOR_M + DEM_EXTRA_M - SHEET_SURFACE_MARGIN_M)
|
||
xs, ys = (start[0], end[0]), (start[1], end[1])
|
||
return np.array(
|
||
[[min(xs) - pad, min(ys) - pad], [max(xs) + pad, max(ys) + pad]], dtype=np.float64
|
||
)
|
||
|
||
|
||
def _read_json(path: Path) -> dict[str, Any] | None:
|
||
try:
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
except (OSError, ValueError):
|
||
return None
|
||
|
||
|
||
def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[str]:
|
||
"""병합 = 지난번 병합 도엽(zip 남은 것) ∪ 이번 도엽 · 열쇠 같으면 건너뜀."""
|
||
key_path = processed / "sheet_merge.key.json"
|
||
old = (_read_json(key_path) or {}).get("sheets") or []
|
||
sheets = sorted({*available, *(s for s in old if get_sheet_path(store, s) is not None)})
|
||
key = {"sheets": sheets}
|
||
if sheets and (_read_json(key_path) != key or not any(processed.glob("도엽_*.geojson"))):
|
||
parse_and_merge_sheets(store, sheets, processed)
|
||
atomic_write_json(key_path, key)
|
||
return sheets
|
||
|
||
|
||
def _satellite(processed: Path, prj: Path, points: list[tuple[float, float]], epsg: int) -> str:
|
||
"""위성 = 기준 도엽 도곽 ∪ 옛 범위 · 이미 덮으면 건너뜀. 돌려줌 = skip · done · failed."""
|
||
from pyproj import Transformer
|
||
|
||
to_metric = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True)
|
||
xs: list[float] = []
|
||
ys: list[float] = []
|
||
for sheet in sheets_for_points(points):
|
||
lon0, lat0, lon1, lat1 = sheet5k_to_bounds(sheet)
|
||
for lon, lat in ((lon0, lat0), (lon0, lat1), (lon1, lat0), (lon1, lat1)):
|
||
x, y = to_metric.transform(lon, lat)
|
||
xs.append(x)
|
||
ys.append(y)
|
||
need = {"x": [min(xs), max(xs)], "y": [min(ys), max(ys)]}
|
||
meta = _read_json(processed / "vworld_satellite_meta.json")
|
||
try:
|
||
old = [float(meta[k]) for k in ("x_min", "x_max", "y_min", "y_max")] if meta else None
|
||
except (KeyError, TypeError, ValueError):
|
||
old = None
|
||
if old and old[0] <= need["x"][0] and old[1] >= need["x"][1]:
|
||
if old[2] <= need["y"][0] and old[3] >= need["y"][1]:
|
||
return "skip"
|
||
if old:
|
||
need = {
|
||
"x": [min(old[0], need["x"][0]), max(old[1], need["x"][1])],
|
||
"y": [min(old[2], need["y"][0]), max(old[3], need["y"][1])],
|
||
}
|
||
try:
|
||
done = download_vworld_satellite_map(prj, need, processed, layer_name="Satellite")
|
||
except Exception as exc: # noqa: BLE001 — 위성은 배경 · 지형 받기를 막지 않음
|
||
logger.warning("D 위성 지도 받기 실패: %s", exc)
|
||
return "failed"
|
||
return "done" if done else "failed"
|
||
|
||
|
||
def ensure_plan_terrain(
|
||
project_root: Path,
|
||
segments: list[dict[str, Any]],
|
||
epsg: int,
|
||
on_progress: Progress | None = None,
|
||
) -> dict[str, Any]:
|
||
"""지점 기준 지형 받기 — `segments` = [{year, start: (x, y), end: (x, y)}](작업 좌표계 m).
|
||
|
||
돌려줌 = 도엽(받은 · 실패 · 병합) · 위성 상태 · 연도별 모델 등록용 dict 목록.
|
||
"""
|
||
|
||
def report(percent: int, message: str) -> None:
|
||
if on_progress is not None:
|
||
on_progress(percent, message)
|
||
|
||
if not segments:
|
||
raise ValueError("연도 구간이 없습니다.")
|
||
processed = project_root / STAGE / "processed"
|
||
processed.mkdir(parents=True, exist_ok=True)
|
||
prj = write_plan_prj(project_root, epsg)
|
||
crs = f"EPSG:{int(epsg)}"
|
||
|
||
report(5, "수치지형도 도엽 고르는 중")
|
||
points = reference_points(segments, epsg)
|
||
wanted = neighbors_for_points(points)
|
||
store = get_project_map_sheets_dir(project_root)
|
||
before = {s for s in wanted if get_sheet_path(store, s) is not None}
|
||
report(10, f"수치지형도 도엽 {len(wanted) - len(before)}매 받는 중")
|
||
got = ensure_sheets(store, wanted)
|
||
if not got["available"]:
|
||
raise ValueError(f"수치지형도 도엽을 하나도 받지 못했습니다: {got['failed']}")
|
||
report(45, "도엽 레이어 병합 중")
|
||
merged = _merge_sheets(processed, store, list(got["available"]))
|
||
report(60, "위성 지도 받는 중")
|
||
satellite = _satellite(processed, prj, points, epsg)
|
||
|
||
years: dict[int, list[dict[str, Any]]] = {}
|
||
for i, seg in enumerate(segments):
|
||
year = int(seg["year"])
|
||
report(65 + 30 * i // len(segments), f"{year}년 구간 지형면 만드는 중")
|
||
models_dir = models_dir_of(project_root, year)
|
||
models_dir.mkdir(parents=True, exist_ok=True)
|
||
years[year] = build_sheet_surface_model(
|
||
project_root,
|
||
processed,
|
||
models_dir,
|
||
dem_outline(seg["start"], seg["end"]),
|
||
crs,
|
||
[SHEET_SURFACE_DEFAULT_METHOD],
|
||
)
|
||
if not years[year]:
|
||
raise ValueError(f"{year}년 구간 지형면을 만들지 못했습니다 — 도엽 등고선 확인")
|
||
report(100, "지형 받기 끝")
|
||
return {
|
||
"sheets": {
|
||
"wanted": len(wanted),
|
||
"downloaded": sorted(set(got["available"]) - before),
|
||
"failed": got["failed"],
|
||
"merged": merged,
|
||
},
|
||
"satellite": satellite,
|
||
"years": years,
|
||
}
|