diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index 5cd23d9e8..490b315a6 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -57,11 +57,14 @@ export interface UploadStatusResponse { */ export class ReplaceOutputsConfirmRequired extends Error { readonly targets: string[]; + /** 지형 파일이 그대로라 전처리 결과를 살리는지 — 「처음부터 다시」를 따로 묻는다. */ + readonly preprocessKept: boolean; - constructor(message: string, targets: string[]) { + constructor(message: string, targets: string[], preprocessKept = false) { super(message); this.name = "ReplaceOutputsConfirmRequired"; this.targets = targets; + this.preprocessKept = preprocessKept; } } @@ -70,12 +73,14 @@ async function readJsonOrThrow(response: Response): Promise { message?: string; confirm?: string; targets?: string[]; + preprocess_kept?: boolean; }; if (!response.ok) { if (response.status === 409 && payload.confirm === "replace_outputs") { throw new ReplaceOutputsConfirmRequired( payload.message ?? "이미 만들어 둔 결과가 지워집니다.", payload.targets ?? [], + payload.preprocess_kept === true, ); } throw new Error(payload.message ?? `HTTP ${response.status}`); @@ -115,6 +120,7 @@ export async function createUploadSession( completeUpload = false, lasFree = false, confirmReplace = false, + freshStart = false, ): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, { method: "POST", @@ -128,6 +134,7 @@ export async function createUploadSession( complete_upload: completeUpload, las_free: lasFree, confirm_replace: confirmReplace, + fresh_start: freshStart, }), }); return await readJsonOrThrow(response); @@ -160,6 +167,7 @@ export async function finalizeUploadSession( fingerprint?: string | null, lasFree = false, confirmReplace = false, + freshStart = false, ): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, { method: "POST", @@ -172,6 +180,7 @@ export async function finalizeUploadSession( fingerprint: fingerprint ?? null, las_free: lasFree, confirm_replace: confirmReplace, + fresh_start: freshStart, }), }); return await readJsonOrThrow(response); diff --git a/B03_FileInput/B03_FileInput_Router_Chunks.py b/B03_FileInput/B03_FileInput_Router_Chunks.py index 81cef6b1f..3f7ca619b 100644 --- a/B03_FileInput/B03_FileInput_Router_Chunks.py +++ b/B03_FileInput/B03_FileInput_Router_Chunks.py @@ -113,6 +113,7 @@ async def create_project_upload_session( project_id, payload.las_free, payload.confirm_replace, + payload.fresh_start, ) await connection.commit() except Exception: @@ -309,6 +310,7 @@ async def finalize_project_upload( project_id, payload.las_free, payload.confirm_replace, + payload.fresh_start, ) await connection.commit() except Exception: diff --git a/B03_FileInput/B03_FileInput_Router_Helpers.py b/B03_FileInput/B03_FileInput_Router_Helpers.py index b088a9928..58fe0ec67 100644 --- a/B03_FileInput/B03_FileInput_Router_Helpers.py +++ b/B03_FileInput/B03_FileInput_Router_Helpers.py @@ -29,7 +29,7 @@ from B03_FileInput.B03_FileInput_Schema import ( ) from common_util.common_util_initial_snapshot import clear_designing, discard_initial_snapshot from common_util.common_util_json import atomic_write_json -from common_util.common_util_project_reset import purge_project_outputs +from common_util.common_util_project_reset import PREPROCESS_STAGE_DIR, purge_project_outputs from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow_state import ( complete_stage, @@ -61,8 +61,10 @@ class OutputsWouldBeDiscarded(Exception): 로 다시 들어온다. 자동 절차는 이 예외를 만들지 않는다 — 부를 때 확인을 켜 준다. """ - def __init__(self, targets: list[str]) -> None: + def __init__(self, targets: list[str], preprocess_kept: bool = False) -> None: self.targets = targets + # 지형 파일이 그대로라 전처리 결과를 살리는지 — 화면이 「처음부터 다시」를 따로 묻는다. + self.preprocess_kept = preprocess_kept super().__init__("설계 산출물이 있어 확인이 필요합니다.") @@ -78,14 +80,17 @@ def _confirm_replace_response(exc: "OutputsWouldBeDiscarded") -> JSONResponse: "아래가 지워지고 **되돌릴 수 없습니다**: " + " · ".join(exc.targets) ), "targets": exc.targets, + "preprocess_kept": exc.preprocess_kept, }, ) -def describe_existing_outputs(project_root: Path) -> list[str]: +def describe_existing_outputs(project_root: Path, keep_preprocess: bool = False) -> list[str]: """새 자료를 받으면 **지워질 것**의 이름을 사람 말로 늘어놓는다. 없으면 빈 목록.""" targets: list[str] = [] for stage, label in _OUTPUT_STAGE_LABELS: + if keep_preprocess and stage == PREPROCESS_STAGE_DIR: + continue stage_root = project_root / stage if not stage_root.is_dir(): continue @@ -201,16 +206,37 @@ async def _already_uploaded( ) +def preprocess_input_unchanged(project_root: Path, terrain_paths: list[Path]) -> bool: + """지형 파일이 마지막 전처리 때와 같은지 — `processed/source_identity.json` 과 대조. + + 같은 지문(크기 + 앞·가운데·끝) 파일은 전송을 건너뛰어 파일이 그대로 남는다 — + 이름·크기·수정시각이 전처리가 적어 둔 값과 같으면 같은 입력이다. + """ + if not terrain_paths: + return False + from B04_PreProcess.B04_PreProcess_Engine import _source_identity + + identity_path = project_root / PREPROCESS_STAGE_DIR / "processed" / "source_identity.json" + try: + stored = json.loads(identity_path.read_text(encoding="utf-8")) + return stored == _source_identity(terrain_paths) + except (OSError, ValueError): + return False + + async def _complete_file_input_if_ready( connection: aiomysql.Connection, project_id: UUID, las_free: bool = False, confirm_replace: bool = True, + fresh_start: bool = False, ) -> int: """새 자료로 갈아 끼운다. `confirm_replace=False` 면 지울 것이 있을 때 멈추고 묻는다. 기본이 `True` 인 까닭 — 자동 절차(체인·스크립트)는 물음에 걸리면 안 된다. 사람이 올리는 갈래(라우터)만 `False` 로 불러 확인을 받는다(2026-09-08 사용자 지시). + 지형 파일이 그대로면 전처리 결과는 살리고 단계 표시만 되돌린다 — + `fresh_start=True`(확인 창의 「처음부터 다시」)면 전처리까지 지운다. """ file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness( connection, project_id @@ -234,14 +260,15 @@ async def _complete_file_input_if_ready( gap_message = merge_gap_error(terrain_paths) if gap_message: raise ValueError(gap_message) + keep_preprocess = not fresh_start and preprocess_input_unchanged(project_root, terrain_paths) # ⚠ 여기서부터는 되돌릴 수 없다 — 아래 셋이 파일까지 지운다. 지울 것이 있으면 먼저 묻는다. if not confirm_replace: - targets = describe_existing_outputs(project_root) + targets = describe_existing_outputs(project_root, keep_preprocess) if targets: - raise OutputsWouldBeDiscarded(targets) + raise OutputsWouldBeDiscarded(targets, keep_preprocess) clear_designing(project_root) discard_initial_snapshot(project_root) - await purge_project_outputs(connection, str(project_id), project_root) + await purge_project_outputs(connection, str(project_id), project_root, keep_preprocess) async with connection.cursor(aiomysql.DictCursor) as cursor: await reset_stages_after_input_change(cursor, str(project_id)) await complete_stage(cursor, str(project_id), 0) diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py index f9ab3a634..39c49d9f8 100644 --- a/B03_FileInput/B03_FileInput_Schema.py +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -65,6 +65,8 @@ class ChunkSessionCreateRequest(FileUploadDescriptor): # 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이 # 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다. confirm_replace: bool = False + # 확인 창의 「처음부터 다시」 — 지형 파일이 그대로여도 전처리 결과까지 지운다. + fresh_start: bool = False class ChunkSessionCreateResponse(BaseModel): @@ -109,6 +111,8 @@ class UploadFinalizeRequest(BaseModel): # 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이 # 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다. confirm_replace: bool = False + # 확인 창의 「처음부터 다시」 — 지형 파일이 그대로여도 전처리 결과까지 지운다. + fresh_start: bool = False class UploadStatusResponse(BaseModel): diff --git a/B03_FileInput/B03_FileInput_UI_Upload.ts b/B03_FileInput/B03_FileInput_UI_Upload.ts index a64cfb981..afba2771a 100644 --- a/B03_FileInput/B03_FileInput_UI_Upload.ts +++ b/B03_FileInput/B03_FileInput_UI_Upload.ts @@ -83,9 +83,11 @@ export function confirmReplaceUpload(slotLabel: string, fileName: string): Promi * 되돌릴 수 없는 삭제라 확인 없이는 진행하지 않는다(2026-09-08 사용자 지시). * 자동 절차는 이 자리를 지나지 않는다 — 서버가 확인을 켜고 부른다. */ -async function askReplaceOutputs(run: (confirmReplace: boolean) => Promise): Promise { +async function askReplaceOutputs( + run: (confirmReplace: boolean, freshStart: boolean) => Promise, +): Promise { try { - return await run(false); + return await run(false, false); } catch (error) { if (!(error instanceof ReplaceOutputsConfirmRequired)) throw error; const detail = error.targets.length @@ -99,7 +101,16 @@ async function askReplaceOutputs(run: (confirmReplace: boolean) => Promise 되돌릴 수 없습니다. 계속할까요?`, ); if (!agreed) throw new Error("업로드를 취소했습니다 — 기존 결과는 그대로 있습니다."); - return await run(true); + // 지형 파일이 그대로면 전처리 결과는 살린다 — 「처음부터 다시」는 여기서 따로 고른다. + const freshStart = + error.preprocessKept && + window.confirm( + `같은 지형 파일이라 전처리 결과(지표면)는 살려 다시 씁니다. + +전처리까지 처음부터 다시 할까요? +[확인] 처음부터 다시 · [취소] 전처리 결과 살림`, + ); + return await run(true, freshStart); } } @@ -123,7 +134,7 @@ export async function uploadOneFile( const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file); let session = state.uploadSessionId; if (!session) { - const created = await askReplaceOutputs((confirmReplace) => + const created = await askReplaceOutputs((confirmReplace, freshStart) => createUploadSession( projectId, file, @@ -132,6 +143,7 @@ export async function uploadOneFile( completeUpload, lasFree, confirmReplace, + freshStart, ), ); if (created.already_uploaded) { @@ -184,7 +196,7 @@ export async function uploadOneFile( } } - const response = await askReplaceOutputs((confirmReplace) => + const response = await askReplaceOutputs((confirmReplace, freshStart) => finalizeUploadSession( projectId, session, @@ -193,6 +205,7 @@ export async function uploadOneFile( fingerprint, lasFree, confirmReplace, + freshStart, ), ); localStorage.removeItem(storageKey); diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index b94ef1c31..8c806ee6b 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -55,6 +55,87 @@ def _source_identity(las_paths: list[Path]) -> dict[str, Any]: return files[0] if len(files) == 1 else {"files": files} +class _LazyXyz: + """structured.npz 의 xyz — 처음 값을 쓸 때만 푼다. 길이는 저장해 둔 점 수로 답한다.""" + + def __init__(self, structured: Any, count: int, array: np.ndarray | None = None) -> None: + self._structured = structured + self._count = count + self._array = array + + def _load(self) -> np.ndarray: + if self._array is None: + self._array = self._structured["xyz"] + return self._array + + def __len__(self) -> int: + return self._count + + def __getitem__(self, key: Any) -> Any: + return self._load()[key] + + def __array__(self, dtype: Any = None, copy: Any = None) -> np.ndarray: + return np.asarray(self._load(), dtype=dtype) + + def __getattr__(self, name: str) -> Any: + return getattr(self._load(), name) + + +class _LazyStructured(dict): + """구조화 결과 — 열쇠마다 처음 쓸 때만 푼다(저장본이 다 맞는 다시 돎은 점군을 안 풂).""" + + def __init__(self, structured: Any, xyz: _LazyXyz) -> None: + super().__init__() + self._structured = structured + self["xyz"] = xyz + + # 옛 dict 와 같은 열쇠만 보인다 — 필터가 열쇠 유무로 갈리는 일이 없게. + _KEYS = ("classification",) + + def __missing__(self, key: str) -> Any: + if key not in self._KEYS: + raise KeyError(key) + value = self._structured[key] + self[key] = value + return value + + def __contains__(self, key: object) -> bool: + return dict.__contains__(self, key) or key in self._KEYS + + +def _structured_stats(structured: Any, structured_path: Path) -> tuple[dict[str, Any], Any]: + """점 수 · 평균 Z 를 `structured_stats.json` 에서 읽는다 — 없거나 낡으면 한 번 풀어 적는다. + + 돌려주는 둘째 값 = 이번에 푼 xyz(없으면 None) — 같은 부름 안에서 다시 풀지 않게. + """ + stats_path = structured_path.parent / "structured_stats.json" + stat = structured_path.stat() + stamp = {"size_bytes": int(stat.st_size), "mtime_ns": int(stat.st_mtime_ns)} + try: + stored = json.loads(stats_path.read_text(encoding="utf-8")) + if stored.get("structured") == stamp: + return stored, None + except (OSError, ValueError, AttributeError): + pass + xyz = structured["xyz"] + count = int(len(xyz)) + stats = { + "structured": stamp, + "point_count": count, + "mean_z": float(np.mean(xyz[:, 2])) if count else None, + } + atomic_write_json(stats_path, stats) + return stats, xyz + + +def _key_matches(key_path: Path, key: Any) -> bool: + """결과 옆 열쇠 파일이 지금 입력과 같은지.""" + try: + return json.loads(key_path.read_text(encoding="utf-8")) == key + except (OSError, ValueError): + return False + + def _relative_to_project(project_root: Path, path: Path) -> str: """프로젝트 루트 기준 posix 상대 경로 문자열.""" return path.relative_to(project_root).as_posix() @@ -179,13 +260,14 @@ def run_surface_analysis( _report(10, "structurize", "구조화 캐시 재사용") logger.info("B04 구조화 캐시 재사용: %s", structured_path.name) with np.load(structured_path) as structured: - xyz = structured["xyz"] + # 점 수 · 평균 Z 는 저장값 — 저장본이 다 맞으면 점군(841 MB 압축)을 풀지 않는다. + stored_stats, loaded_xyz = _structured_stats(structured, structured_path) bounds = structured["bounds"] - total_points = int(len(xyz)) + total_points = int(stored_stats["point_count"]) stats = { "min_z": float(bounds[2, 0]), "max_z": float(bounds[2, 1]), - "mean_z": float(np.mean(xyz[:, 2])) if total_points else None, + "mean_z": stored_stats["mean_z"], } bounds_dict = { "x_min": float(bounds[0, 0]), @@ -193,7 +275,8 @@ def run_surface_analysis( "y_min": float(bounds[1, 0]), "y_max": float(bounds[1, 1]), } - data = {"xyz": xyz, "bounds": bounds, "classification": structured["classification"]} + data = _LazyStructured(structured, _LazyXyz(structured, total_points, loaded_xyz)) + data["bounds"] = bounds # 2. 지면 필터 실행 — mask_{filter}.npy 영구 캐시 우선 재사용 (PLAN A-1) _report(40, "ground_filter", "지면 필터 적용 중") @@ -386,14 +469,21 @@ def download_geodata( # 등고선은 국가 gpkg 추가 시점이 전처리보다 늦을 수 있으므로 개별로 존재 여부를 확인한다. contour_geojson = processed_dir / "등고선_bounds.geojson" + # 열쇠 = 받은 범위 · 좌표계 — 노선이 바뀌어 범위가 달라지면 다시 받는다. + gis_key_path = processed_dir / "gis_vectors.key.json" + gis_key = json.loads( + json.dumps({"epsg": project_epsg, "bounds": bounds_dict_for_download}, default=float) + ) if ( rebuild or not any(processed_dir.glob("*_bounds.geojson")) or not contour_geojson.exists() + or not _key_matches(gis_key_path, gis_key) ): try: step_started = time.monotonic() download_all_gis_vectors(prj_path, bounds_dict_for_download, processed_dir) + atomic_write_json(gis_key_path, gis_key) logger.info( "B04 국가 GIS 벡터 다운로드 완료 (%.1fs)", time.monotonic() - step_started ) @@ -433,9 +523,13 @@ def download_geodata( # 선정에서 빠진 zip은 쓰이지 않으므로 정리한다(다른 지역 잔재 포함). prune_sheets(sheet_store, sheet_grid) - # 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 (기존 산출물 있으면 스킵) + # 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 — 열쇠(도엽 번호 목록)가 같으면 스킵 + merge_key_path = processed_dir / "sheet_merge.key.json" + merge_key = {"sheets": sorted(str(item) for item in sheet_result["available"])} if sheet_result["available"] and ( - rebuild or not any(processed_dir.glob("도엽_*.geojson")) + rebuild + or not any(processed_dir.glob("도엽_*.geojson")) + or not _key_matches(merge_key_path, merge_key) ): from B04_PreProcess.B04_PreProcess_Engine_SheetParser import ( parse_and_merge_sheets, @@ -450,6 +544,7 @@ def download_geodata( {k: v["count"] for k, v in merged.items()}, time.monotonic() - step_started, ) + atomic_write_json(merge_key_path, merge_key) except Exception as exc: logger.warning("B04 수치지형도 도엽 확보 실패: %s", exc) except Exception as exc: diff --git a/B04_PreProcess/B04_PreProcess_Engine_MvtHelper.py b/B04_PreProcess/B04_PreProcess_Engine_MvtHelper.py index c945e8f3a..8e0b09265 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_MvtHelper.py +++ b/B04_PreProcess/B04_PreProcess_Engine_MvtHelper.py @@ -18,7 +18,7 @@ try: except ImportError: mapbox_vector_tile = None -# 메모리 캐시: {project_id}_{layer_name}: GeoDataFrame +# 메모리 캐시: {project_id}_{layer_name}: ((mtime_ns, 크기), GeoDataFrame) _gdf_cache: dict[str, Any] = {} @@ -41,9 +41,13 @@ def get_tile_bbox(xtile: int, ytile: int, zoom: int) -> tuple[float, float, floa def load_geojson_cached(filepath: Path, cache_key: str) -> Any: """GeoJSON 파일을 한 번 읽으면 메모리에 GeoDataFrame으로 캐싱합니다.""" global _gdf_cache - if cache_key not in _gdf_cache: - if not filepath.exists(): - raise FileNotFoundError(f"File not found: {filepath}") + if not filepath.exists(): + raise FileNotFoundError(f"File not found: {filepath}") + # 열쇠에 수정시각·크기 — 전처리를 다시 돌려 파일이 바뀌면 옛 타일을 버린다. + stat = filepath.stat() + stamp = (stat.st_mtime_ns, stat.st_size) + cached = _gdf_cache.get(cache_key) + if cached is None or cached[0] != stamp: if gpd is None: raise ImportError("geopandas가 설치되어 있지 않습니다.") gdf = gpd.read_file(filepath) @@ -51,8 +55,8 @@ def load_geojson_cached(filepath: Path, cache_key: str) -> Any: gdf.set_crs(epsg=4326, inplace=True) if gdf.crs.to_epsg() != 4326: gdf = gdf.to_crs(epsg=4326) - _gdf_cache[cache_key] = gdf - return _gdf_cache[cache_key] + _gdf_cache[cache_key] = (stamp, gdf) + return _gdf_cache[cache_key][1] def generate_mvt_tile( diff --git a/common_util/common_util_project_reset.py b/common_util/common_util_project_reset.py index c8780c718..b4e02fec9 100644 --- a/common_util/common_util_project_reset.py +++ b/common_util/common_util_project_reset.py @@ -41,10 +41,17 @@ _PROJECT_OUTPUT_TABLES = ( ) -def purge_output_folders(project_root: Path) -> list[str]: +# 같은 지형 파일을 다시 올렸을 때 살리는 것 — 전처리 폴더와 그 DB 행. +PREPROCESS_STAGE_DIR = "B04_PreProcess" +_PREPROCESS_TABLES = frozenset({"surface_models", "processed_point_cloud"}) + + +def purge_output_folders(project_root: Path, keep_preprocess: bool = False) -> list[str]: """단계별 산출물 폴더를 비운다. 지운 폴더 이름을 돌려준다.""" removed: list[str] = [] for name in OUTPUT_STAGE_DIRS: + if keep_preprocess and name == PREPROCESS_STAGE_DIR: + continue target = project_root / name if not target.exists(): continue @@ -54,7 +61,9 @@ def purge_output_folders(project_root: Path) -> list[str]: return removed -async def purge_output_records(connection: aiomysql.Connection, project_id: str) -> dict[str, int]: +async def purge_output_records( + connection: aiomysql.Connection, project_id: str, keep_preprocess: bool = False +) -> dict[str, int]: """산출물 DB 레코드를 지운다. 파일만 지우면 화면은 "자료가 있다"고 착각한다. 호출부가 트랜잭션을 관리한다(여기서 커밋하지 않는다). @@ -75,6 +84,8 @@ async def purge_output_records(connection: aiomysql.Connection, project_id: str) ) deleted["route_statistics"] = cursor.rowcount for table in _PROJECT_OUTPUT_TABLES: + if keep_preprocess and table in _PREPROCESS_TABLES: + continue await cursor.execute(f"DELETE FROM {table} WHERE project_id = %s", (project_id,)) deleted[table] = cursor.rowcount await cursor.execute("DELETE FROM routes WHERE project_id = %s", (project_id,)) @@ -83,14 +94,21 @@ async def purge_output_records(connection: aiomysql.Connection, project_id: str) async def purge_project_outputs( - connection: aiomysql.Connection, project_id: str, project_root: Path + connection: aiomysql.Connection, + project_id: str, + project_root: Path, + keep_preprocess: bool = False, ) -> None: - """입력 자료 교체 시 옛 계산 결과(파일 + DB)를 함께 지운다.""" - removed = purge_output_folders(project_root) - deleted = await purge_output_records(connection, project_id) + """입력 자료 교체 시 옛 계산 결과(파일 + DB)를 함께 지운다. + + `keep_preprocess` — 지형 파일이 그대로면 전처리(B04) 결과는 살린다. + """ + removed = purge_output_folders(project_root, keep_preprocess) + deleted = await purge_output_records(connection, project_id, keep_preprocess) logger.info( - "입력 자료 교체 — 옛 산출물 정리: project_id=%s 폴더=%s 레코드=%s", + "입력 자료 교체 — 옛 산출물 정리: project_id=%s 전처리 살림=%s 폴더=%s 레코드=%s", project_id, + keep_preprocess, ",".join(removed) or "없음", {key: value for key, value in deleted.items() if value}, ) diff --git a/resources/tester/test_343_1_preprocess_keep.py b/resources/tester/test_343_1_preprocess_keep.py new file mode 100644 index 000000000..79604d880 --- /dev/null +++ b/resources/tester/test_343_1_preprocess_keep.py @@ -0,0 +1,246 @@ +"""343-1 같은 파일 다시 올림 = 전처리 결과 살림 · 점군 안 풂 · 도엽 병합 · 타일 기억 열쇠. + +서버 함수만 시험 — 실제 재올림은 하지 않음(완료 메일). +""" + +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path + +import numpy as np + +from B03_FileInput.B03_FileInput_Router_Helpers import ( + OutputsWouldBeDiscarded, + _confirm_replace_response, + describe_existing_outputs, + preprocess_input_unchanged, +) +from B04_PreProcess.B04_PreProcess_Engine import ( + _key_matches, + _LazyStructured, + _LazyXyz, + _source_identity, + _structured_stats, +) +from B04_PreProcess.B04_PreProcess_Engine_Ground import summarize_masks +from common_util.common_util_project_reset import purge_output_folders, purge_output_records + + +def _project(tmp_path: Path) -> tuple[Path, Path]: + las = tmp_path / "B03_FileInput" / "input" / "a.las" + las.parent.mkdir(parents=True) + las.write_bytes(b"LASF" + b"0" * 100) + processed = tmp_path / "B04_PreProcess" / "processed" + processed.mkdir(parents=True) + (processed / "source_identity.json").write_text( + json.dumps(_source_identity([las])), encoding="utf-8" + ) + return tmp_path, las + + +def test_같은_지형_파일이면_전처리_살림(tmp_path: Path) -> None: + root, las = _project(tmp_path) + assert preprocess_input_unchanged(root, [las]) is True + + +def test_지형_파일이_바뀌면_살리지_않음(tmp_path: Path) -> None: + root, las = _project(tmp_path) + stat = las.stat() + os.utime(las, ns=(stat.st_atime_ns, stat.st_mtime_ns + 5_000_000_000)) + assert preprocess_input_unchanged(root, [las]) is False + assert preprocess_input_unchanged(root, []) is False + + +def test_전처리_기록이_없으면_살리지_않음(tmp_path: Path) -> None: + root, las = _project(tmp_path) + (root / "B04_PreProcess" / "processed" / "source_identity.json").unlink() + assert preprocess_input_unchanged(root, [las]) is False + + +def test_살리면_확인_창_목록에서_전처리_빠짐(tmp_path: Path) -> None: + root, _ = _project(tmp_path) + (root / "B05_Profile").mkdir() + (root / "B05_Profile" / "route.json").write_text("{}", encoding="utf-8") + assert "지표면·전처리 결과" in describe_existing_outputs(root) + kept = describe_existing_outputs(root, keep_preprocess=True) + assert "지표면·전처리 결과" not in kept + assert "노선·종단 설계" in kept + + +def test_확인_응답에_전처리_살림_표시() -> None: + body = json.loads(_confirm_replace_response(OutputsWouldBeDiscarded(["횡단 설계"], True)).body) + assert body["preprocess_kept"] is True + body = json.loads(_confirm_replace_response(OutputsWouldBeDiscarded(["횡단 설계"])).body) + assert body["preprocess_kept"] is False + + +def test_폴더_지우기는_전처리만_남김(tmp_path: Path) -> None: + root, _ = _project(tmp_path) + (root / "B05_Profile").mkdir() + (root / "B05_Profile" / "route.json").write_text("{}", encoding="utf-8") + removed = purge_output_folders(root, keep_preprocess=True) + assert "B04_PreProcess" not in removed + assert (root / "B04_PreProcess" / "processed" / "source_identity.json").is_file() + assert not (root / "B05_Profile" / "route.json").exists() + assert "B04_PreProcess" in purge_output_folders(root) + assert not (root / "B04_PreProcess" / "processed").exists() + + +class _Cursor: + def __init__(self, log: list[str]) -> None: + self.log = log + self.rowcount = 0 + + async def __aenter__(self) -> "_Cursor": + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def execute(self, sql: str, _params: object = None) -> None: + self.log.append(sql) + + +class _Connection: + def __init__(self) -> None: + self.log: list[str] = [] + + def cursor(self) -> _Cursor: + return _Cursor(self.log) + + +def test_DB_지우기는_지표면_행을_남김() -> None: + connection = _Connection() + asyncio.run(purge_output_records(connection, "p", keep_preprocess=True)) # type: ignore[arg-type] + joined = "\n".join(connection.log) + assert "surface_models" not in joined + assert "processed_point_cloud" not in joined + assert "DELETE FROM routes" in joined + connection = _Connection() + asyncio.run(purge_output_records(connection, "p")) # type: ignore[arg-type] + assert "surface_models" in "\n".join(connection.log) + + +def test_점_수_평균Z_저장값_두번째는_안_풂(tmp_path: Path) -> None: + path = tmp_path / "structured.npz" + xyz = np.array([[0, 0, 1], [1, 1, 3]], dtype=np.float32) + np.savez_compressed(path, xyz=xyz, bounds=np.zeros((3, 2))) + with np.load(path) as structured: + stats, loaded = _structured_stats(structured, path) + assert stats["point_count"] == 2 and stats["mean_z"] == 2.0 + assert loaded is not None + with np.load(path) as structured: + stats, loaded = _structured_stats(structured, path) + assert loaded is None and stats["point_count"] == 2 + # 구조화가 다시 돌면(파일이 바뀌면) 다시 셈 + np.savez_compressed(path, xyz=np.zeros((5, 3), np.float32), bounds=np.zeros((3, 2))) + with np.load(path) as structured: + stats, loaded = _structured_stats(structured, path) + assert stats["point_count"] == 5 and loaded is not None + + +class _NoXyz(dict): + files = ["xyz", "bounds", "classification"] + + def __getitem__(self, key: str) -> object: + if key == "xyz": + raise AssertionError("점군을 풀면 안 됨") + return dict.__getitem__(self, key) + + +def test_저장본이_맞으면_점군을_풀지_않음() -> None: + source = _NoXyz(classification=np.zeros(3, np.uint8), bounds=np.zeros((3, 2))) + data = _LazyStructured(source, _LazyXyz(source, 3)) + summary = summarize_masks(data, {"csf": np.array([True, False, True])}) + assert summary["csf"]["total_point_count"] == 3 + assert "classification" in data and "rgb" not in data + assert len(data["classification"]) == 3 + + +def test_점군은_쓸_때_풂() -> None: + xyz = np.arange(9, dtype=np.float32).reshape(3, 3) + lazy = _LazyXyz({"xyz": xyz}, 3) + assert lazy.shape == (3, 3) and lazy.ndim == 2 + assert np.asarray(lazy, dtype=np.float64)[2, 2] == 8.0 + assert lazy[1, 0] == 3.0 + + +def test_결과_옆_열쇠_대조(tmp_path: Path) -> None: + key_path = tmp_path / "sheet_merge.key.json" + key = {"sheets": ["37612001", "37612002"]} + assert _key_matches(key_path, key) is False + key_path.write_text(json.dumps(key), encoding="utf-8") + assert _key_matches(key_path, key) is True + assert _key_matches(key_path, {"sheets": ["37612001"]}) is False + + +def test_지도_타일_기억은_파일이_바뀌면_다시_읽음(tmp_path: Path) -> None: + from B04_PreProcess.B04_PreProcess_Engine_MvtHelper import load_geojson_cached + + path = tmp_path / "등고선_bounds.geojson" + + def write(count: int) -> None: + features = [ + { + "type": "Feature", + "properties": {"h": index}, + "geometry": {"type": "Point", "coordinates": [127.0 + index * 0.01, 37.0]}, + } + for index in range(count) + ] + path.write_text( + json.dumps({"type": "FeatureCollection", "features": features}), encoding="utf-8" + ) + + write(1) + assert len(load_geojson_cached(path, "t343_contour")) == 1 + assert len(load_geojson_cached(path, "t343_contour")) == 1 + write(3) + stat = path.stat() + os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 5_000_000_000)) + assert len(load_geojson_cached(path, "t343_contour")) == 3 + + +def test_다시_돌면_같은_결과_점군_안_풂(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def] + """작은 LAS 로 전처리 두 번 — 두 번째는 저장본만 읽고 점군 xyz 를 풀지 않음.""" + import laspy + + import B04_PreProcess.B04_PreProcess_Engine as engine + import B04_PreProcess.B04_PreProcess_Engine_SheetSurface as sheet + + rng = np.random.default_rng(1) + count = 4000 + x = rng.uniform(200000, 200060, count) + y = rng.uniform(500000, 500060, count) + header = laspy.LasHeader(point_format=3, version="1.2") + header.offsets = [200000, 500000, 0] + header.scales = [0.01, 0.01, 0.01] + las_data = laspy.LasData(header) + las_data.x, las_data.y, las_data.z = x, y, 100 + 0.1 * x - 0.1 * 200000 + las_data.classification = np.full(count, 2, np.uint8) + las_path = tmp_path / "B03_FileInput" / "input" / "t.las" + las_path.parent.mkdir(parents=True) + las_data.write(las_path) + + monkeypatch.setattr(engine, "download_geodata", lambda *a, **k: None) + monkeypatch.setattr(sheet, "build_sheet_surface_from_route", lambda *a, **k: []) + first = engine.run_surface_analysis(tmp_path, las_path, source_filters=None, methods=["dtm"]) + + real_lazy_load = engine._LazyXyz._load + calls: list[int] = [] + + def counting_load(self): # type: ignore[no-untyped-def] + calls.append(1) + return real_lazy_load(self) + + monkeypatch.setattr(engine._LazyXyz, "_load", counting_load) + second = engine.run_surface_analysis(tmp_path, las_path, source_filters=None, methods=["dtm"]) + assert first["models"] and calls == [] + assert second["processed"] == first["processed"] + assert second["ground_summary"] == first["ground_summary"] + assert [m["model_file_path"] for m in second["models"]] == [ + m["model_file_path"] for m in first["models"] + ]