perf(전처리): 343-1 같은 지형 파일 다시 올림 = 전처리 결과 살림 · 다시 돌 때 점군 안 풂 · 도엽 병합 · 국가 GIS · 타일 기억 열쇠

- B03 재올림 — 지형 파일이 source_identity.json 과 같으면 B04 폴더 · 지표면 DB 행 남기고 단계 표시만 되돌림
- 확인 창 「처음부터 다시」 따로 고름(fresh_start) · 409 응답에 preprocess_kept
- B04 다시 돎 — 점 수 · 평균 Z 를 structured_stats.json 에 · xyz 는 쓸 때만 풂
- 도엽 병합 열쇠 = 도엽 번호 목록 · 국가 GIS 열쇠 = 범위 · 좌표계
- 지도 타일 메모리 기억 열쇠에 mtime · 크기
- 시험 test_343_1_preprocess_keep 13건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-16 11:00:04 +09:00
co-authored by Claude Opus 5.5
parent 2879f9bea9
commit cf5de56418
9 changed files with 449 additions and 31 deletions
+10 -1
View File
@@ -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<T>(response: Response): Promise<T> {
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<ChunkSessionCreateResponse> {
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<ChunkSessionCreateResponse>(response);
@@ -160,6 +167,7 @@ export async function finalizeUploadSession(
fingerprint?: string | null,
lasFree = false,
confirmReplace = false,
freshStart = false,
): Promise<FileUploadResponse> {
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<FileUploadResponse>(response);
@@ -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:
+33 -6
View File
@@ -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)
+4
View File
@@ -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):
+18 -5
View File
@@ -83,9 +83,11 @@ export function confirmReplaceUpload(slotLabel: string, fileName: string): Promi
* 되돌릴 수 없는 삭제라 확인 없이는 진행하지 않는다(2026-09-08 사용자 지시).
* 자동 절차는 이 자리를 지나지 않는다 — 서버가 확인을 켜고 부른다.
*/
async function askReplaceOutputs<T>(run: (confirmReplace: boolean) => Promise<T>): Promise<T> {
async function askReplaceOutputs<T>(
run: (confirmReplace: boolean, freshStart: boolean) => Promise<T>,
): Promise<T> {
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<T>(run: (confirmReplace: boolean) => Promise<T>
되돌릴 수 없습니다. 계속할까요?`,
);
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);
+101 -6
View File
@@ -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:
@@ -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(
+25 -7
View File
@@ -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},
)
@@ -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"]
]