"""D 예정노선 지형 받기 — 사업 전체 한 벌(도엽 · 위성 · DEM) (요구 345 · 389 · 463 · PLAN 530-1 · 561-2 · 595 · 608-1). 지형은 D 프로젝트 storage 안 `B04_PreProcess/` 배치 그대로(지형 코드 주인 = B04 · 고칠 곳 0): - 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음) · 새로(`reset` · 위치를 멀리 옮김) = 옛 받은 범위 · 병합 도엽 · 위성 범위를 버리고 새 상자만 · 칸 상한 도엽 `MAX_SHEETS`(공용 창고 · 넘으면 라우터가 거절) - 도엽 = 범위 상자에 닿는 도엽 모두 → `ensure_sheets`(없는 것만 · prune 없음) - 병합 = 지난번 병합 도엽 ∪ 이번 도엽(옛 도엽 유지 · 열쇠 같으면 건너뜀) - 위성 = 범위 도엽 도곽 ∪ 옛 위성 범위(덮으면 건너뜀) - 지적도 = 지형 일에서 뺌 — 겹을 켤 때 GET 이 받음(`D03_PlanAnalysis_Layers.ensure_cadastral`) - DEM = 공용 지형 창고(`common_util_terrain_store` · 도엽마다 처음 한 번 셈) 도엽을 받은 범위로 이어 붙여 한 벌 `models_plan/`(뒤 소비자가 읽는 꼴 그대로 · 열쇠 = 이어 붙인 값 지문) · 도엽 zip = 보조 겹(하천 · 도로 등)만 · 받은 범위 = `plan_terrain.json` (상자 · 여유 · 좌표계 · 위치 칸 가운데) - 작업 좌표계 = 지점 epsg · `B03_FileInput/input/prj/plan.prj` 한 장(B04 · B05 좌표계 창구가 읽음) - 받기 전 범위 보기 = `plan_extent`(내려받기 없음) · 받기는 사용자가 누를 때만 계산 자리 = ③ 서버 단독. DB 행(`surface_models`)은 라우터가 씀. """ from __future__ import annotations import hashlib 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 ( SHEET5K_SIZE_DEG, sheet5k_to_bounds, sheets_for_points, ) from B04_PreProcess.B04_PreProcess_Engine_SheetParser import PARSER_VERSION, 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 ( _key_path, _method_model_entry, _write_method_model, ) from B04_PreProcess.B04_PreProcess_Engine_VWorld import download_vworld_satellite_map from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec from common_util import common_util_terrain_store as terrain_store from common_util.common_util_json import atomic_write_json from common_util.common_util_terrain_store import load_terrain from config.config_system_terrain import ( PLAN_TERRAIN_GRID_M, SHEET_SURFACE_DEFAULT_METHOD, SHEET_SURFACE_MARGIN_M, ) logger = logging.getLogger(__name__) # 여유 처음 값 = 옛 연도 구간 DEM 폭(탐색 복도 300 m + 횡단 표본 50 m) · 바닥 = B 절취 여유 DEFAULT_MARGIN_M = 350.0 MAX_MARGIN_M = 5000.0 # 처음 받는 범위 = 등록 기준점 둘레 ± 1 km(요구 453 · PLAN 591-2) SITE_RADIUS_M = 1000.0 # 위치 바꿈이 받은 범위에서 이만큼 넘게 멀면 묻고 새로 · 받을 도엽 상한(요구서_463 3장 4 · 613-1) FAR_M = 5000.0 MAX_SHEETS = 36 # 한 번에 이어 붙이는 공용 창고 도엽 수 상한(6 × 6) # ponytail: 새 도엽 함께 셀 수 — 장당 메모리 1 GB 안팎 · 넘으면 차례로 WARM_WORKERS = 4 STAGE = Path("B04_PreProcess") PLAN_MODELS = "models_plan" TERRAIN_META = "plan_terrain.json" PRJ_NAME = "plan.prj" Progress = Callable[[int, str], None] Box = tuple[float, float, float, float] # x0, y0, x1, y1(작업 좌표계 m) XY = tuple[float, float] def models_dir_of(project_root: Path) -> Path: """사업 전체 DEM 자리.""" return project_root / STAGE / PLAN_MODELS def clamp_margin(margin_m: float | None) -> float: """여유 — 빈 값 = 처음 값 · B 절취 여유 ~ 5 km.""" if margin_m is None or not math.isfinite(margin_m): return DEFAULT_MARGIN_M return min(MAX_MARGIN_M, max(SHEET_SURFACE_MARGIN_M, float(margin_m))) def received_meta(project_root: Path) -> dict[str, Any]: """받은 범위 기록(`box` · `margin_m` · `epsg` · `center`) — 없으면 빈 dict.""" return _read_json(models_dir_of(project_root) / TERRAIN_META) or {} def received_box(project_root: Path, epsg: int | None = None) -> Box | None: """받은 DEM 범위 — 받은 적 없거나 읽지 못하거나 다른 좌표계(`epsg` 줄 때)면 None.""" meta = received_meta(project_root) if epsg is not None and meta.get("epsg") not in (None, int(epsg)): return None try: x0, y0, x1, y1 = (float(v) for v in meta["box"]) except (KeyError, TypeError, ValueError): return None return x0, y0, x1, y1 def site_corners(x: float, y: float) -> list[XY]: """기준점 둘레 상자 두 귀 — 처음 여유를 더하면 ± `SITE_RADIUS_M`.""" r = SITE_RADIUS_M - DEFAULT_MARGIN_M return [(x - r, y - r), (x + r, y + r)] def wanted_box(points: list[XY], margin_m: float, have: Box | None) -> Box: """받을 범위 = 점 상자 + 여유 ∪ 받은 범위.""" xs, ys = [p[0] for p in points], [p[1] for p in points] box = (min(xs) - margin_m, min(ys) - margin_m, max(xs) + margin_m, max(ys) + margin_m) if have is None: return box return min(box[0], have[0]), min(box[1], have[1]), max(box[2], have[2]), max(box[3], have[3]) def cells_of(box: Box) -> int: """DEM 칸 수 어림 — 받기 시간 · 메모리 가늠.""" return int((box[2] - box[0]) * (box[3] - box[1]) / PLAN_TERRAIN_GRID_M**2) def store_sheet_count(box: Box, epsg: int) -> int: """공용 창고에서 이어 붙일 도엽 수 — 상한 판정.""" return len(terrain_store.sheets_for_box(box, epsg)) def sheet_limit_message(count: int) -> str: """도엽 상한 넘음 알림 글 — 몇 장 · 줄이는 법.""" return ( f"받을 범위가 너무 넓습니다 — 도엽 {count}장 · 한 번에 {MAX_SHEETS}장까지" "(지점 · 선을 모으거나 위치를 옮겨 새로 받으세요)" ) def distance_to_box(xy: XY, box: Box) -> float: """점 ~ 상자 거리(m · 안이면 0).""" dx = max(box[0] - xy[0], 0.0, xy[0] - box[2]) dy = max(box[1] - xy[1], 0.0, xy[1] - box[3]) return math.hypot(dx, dy) def covers(have: Box | None, box: Box) -> bool: return ( have is not None and all(have[i] <= box[i] for i in (0, 1)) and all(have[i] >= box[i] for i in (2, 3)) ) def box_ring(box: Box, epsg: int) -> list[list[float]]: """상자 네 귀 경위도([경도, 위도] · 왼아래부터 반시계).""" from common_util.common_util_crs import tm_to_lonlat x0, y0, x1, y1 = box return [list(tm_to_lonlat(x, y, epsg)) for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1))] def sheets_for_box(box: Box, epsg: int) -> list[str]: """상자에 닿는 도엽 — 경위도 외접 상자를 도엽 반 칸 간격으로 짚음.""" ring = box_ring(box, epsg) lons, lats = [p[0] for p in ring], [p[1] for p in ring] step = SHEET5K_SIZE_DEG / 2 def ticks(lo: float, hi: float) -> list[float]: return [lo + step * i for i in range(int((hi - lo) / step) + 1)] + [hi] grid = [ (lat, lon) for lat in ticks(min(lats), max(lats)) for lon in ticks(min(lons), max(lons)) ] return sorted(set(sheets_for_points(grid))) def plan_extent( project_root: Path, points: list[XY], epsg: int, margin_m: float | None = None ) -> dict[str, Any]: """받기 전 범위 보기 — 내려받기 없음. `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m).""" margin = clamp_margin(margin_m) have = received_box(project_root, epsg) box = wanted_box(points, margin, have) store = get_project_map_sheets_dir(project_root) sheets = [] for no in sheets_for_box(box, epsg): lon0, lat0, lon1, lat1 = sheet5k_to_bounds(no) sheets.append( { "no": no, "ring": [[lon0, lat0], [lon1, lat0], [lon1, lat1], [lon0, lat1]], "stored": get_sheet_path(store, no) is not None, } ) return { "epsg": int(epsg), "margin_m": margin, "box_m": list(box), "box": box_ring(box, epsg), "have_m": list(have) if have else None, "have": box_ring(have, epsg) if have else None, "covered": covers(have, box), "sheets": sheets, "cells": cells_of(box), "store_sheets": store_sheet_count(box, epsg), "max_sheets": MAX_SHEETS, } 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 _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], keep_old: bool = True ) -> list[str]: """병합 = 지난번 병합 도엽(zip 남은 것 · 새로면 뺌) ∪ 이번 도엽 · 열쇠 같으면 건너뜀.""" key_path = processed / "sheet_merge.key.json" old = ((_read_json(key_path) or {}).get("sheets") or []) if keep_old else [] sheets = sorted({*available, *(s for s in old if get_sheet_path(store, s) is not None)}) key = {"sheets": sheets, "parser": PARSER_VERSION} 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, keep_old: bool = True, ) -> 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") if keep_old else None 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, points: list[XY], epsg: int, margin_m: float | None = None, on_progress: Progress | None = None, reset: bool = False, center: tuple[float, float] | None = None, ) -> dict[str, Any]: """사업 전체 지형 받기 — `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m) · `reset` = 옛 받은 범위 버리고 새로 · `center` = 위치 칸 가운데(경위도 · 기록만). 돌려줌 = 도엽(받은 · 실패 · 병합) · 위성 상태 · 받은 범위 · 모델 등록용 dict 목록. """ def report(percent: int, message: str) -> None: if on_progress is not None: on_progress(percent, message) if not points: raise ValueError("시점 · 종점이 없습니다.") processed = project_root / STAGE / "processed" processed.mkdir(parents=True, exist_ok=True) prj = write_plan_prj(project_root, epsg) margin = clamp_margin(margin_m) have = None if reset else received_box(project_root, epsg) box = wanted_box(points, margin, have) report(5, "1/5 수치지형도 도엽 고르는 중") wanted = sheets_for_box(box, epsg) 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"2/5 수치지형도 도엽 {len(wanted) - len(before)}매 받는 중") got = ensure_sheets(store, wanted) if not got["available"]: # 도엽 = 보조 겹만 · 지형은 창고가 셈 logger.warning("D 수치지형도 도엽 받기 실패: %s", got["failed"]) report(45, "3/5 도엽 레이어 병합 중") merged = _merge_sheets(processed, store, list(got["available"]), keep_old=have is not None) report(60, "4/5 위성 지도 받는 중") ring = [(p[1], p[0]) for p in box_ring(box, epsg)] satellite = _satellite(processed, prj, ring, epsg, keep_old=have is not None) report(65, f"5/5 사업 지형면 — 칸 {cells_of(box) / 1e4:.0f}만") models_dir = models_dir_of(project_root) models_dir.mkdir(parents=True, exist_ok=True) warmed = _warm_store(box, epsg, lambda pct, msg: report(65 + pct * 30 // 100, f"5/5 {msg}")) def store_report(percent: int, message: str) -> None: report(95 + percent * 4 // 100, f"5/5 {message}") grid = load_terrain(epsg, box=box, cell_m=PLAN_TERRAIN_GRID_M, on_progress=store_report) models = _write_store_model(project_root, models_dir, grid) if not models: raise ValueError( "사업 지형면을 만들지 못했습니다 — 범위 안 등고선 확인" f"(도엽 {len(grid['sheets'])}매 · 실패 {grid['failed'] or '없음'})" ) meta: dict[str, Any] = {"box": list(box), "margin_m": margin, "epsg": int(epsg)} kept = None if reset else received_meta(project_root).get("center") if center or kept: meta["center"] = list(center or kept) atomic_write_json(models_dir / TERRAIN_META, meta) report(100, "지형 받기 끝") return { "sheets": { "wanted": len(wanted), "downloaded": sorted(set(got["available"]) - before), "failed": got["failed"], "merged": merged, }, "satellite": satellite, "box_m": list(box), "models": models, "terrain": {"computed": warmed + grid["computed"], "failed": grid["failed"]}, } def _warm_store(box: Box, epsg: int, report: Progress) -> list[str]: """창고에 없는 도엽을 함께 셈(스레드 · 장당 약 50 s · 둘이면 약 절반) — 실패는 이어 붙일 때 다시 물어 `failed` 에 남음.""" from concurrent.futures import ThreadPoolExecutor, as_completed cold = [ no for no in terrain_store.sheets_for_box(box, epsg) if terrain_store._stored_ok(no, epsg, PLAN_TERRAIN_GRID_M) is None ] if not cold: return [] report(0, f"공용 지형 새 도엽 {len(cold)}매 계산 중 — 장당 1 분 안팎") workers = min(WARM_WORKERS, len(cold)) with ThreadPoolExecutor(workers, thread_name_prefix="plan-terrain") as pool: jobs = [ pool.submit(terrain_store.ensure_sheet_terrain, no, epsg, PLAN_TERRAIN_GRID_M) for no in cold ] for done, job in enumerate(as_completed(jobs), 1): if job.exception() is not None: logger.warning("D 공용 지형 도엽 셈 실패: %s", job.exception()) report(done * 100 // len(cold), f"공용 지형 도엽 {done}/{len(cold)}매") return cold def _write_store_model( project_root: Path, models_dir: Path, grid: dict[str, Any] ) -> list[dict[str, Any]]: """창고 격자 → `dtm_sheet_<방법>.npz` · 미리보기 · 평활본(B 꼴) — 값 지문 같으면 그대로.""" z, x, y = grid["z"], grid["x"], grid["y"] if not z.size or not grid["valid_mask"].any(): return [] cell = grid["cell_m"] method = SHEET_SURFACE_DEFAULT_METHOD key = { "source": "terrain_store", "origin": [float(x[0]), float(y[0])], "shape": list(z.shape), "z": hashlib.sha1(np.ascontiguousarray(z).tobytes()).hexdigest(), } entry = _method_model_entry(project_root, models_dir, method, cell) key_path = _key_path(models_dir, method) files = [entry["model_file_path"], *(layer["file_path"] for layer in entry["layers"])] if _read_json(key_path) == key and all((project_root / f).is_file() for f in files): return [entry] key_path.unlink(missing_ok=True) # 쓰는 도중 끊기면 옛 열쇠가 새 파일을 덮지 않게 spec = GridSpec( x_min=float(x[0]) - cell / 2, y_max=float(y[-1]) + cell / 2, cell_m=cell, n_rows=z.shape[0], n_cols=z.shape[1], ) model = _write_method_model(project_root, models_dir, spec, z[::-1, :], method, cell) if model is None: return [] atomic_write_json(key_path, key) return [model]