perf(영구저장): 343-2 도엽 3D 면 열쇠 · 지표면 모델 id 유지 · 비용면 메모리 기억 · 추천 노선 열쇠 · 횡단 상세 ETag

- 도엽 3D 면 — 결과 옆 dtm_sheet_{방식}.key.json(등고선 이름:mtime:크기 · 노선 · CRS · 격자 · 여유 · 스무딩 · 화면 상자 · ALGO) 같으면 건너뜀
- 지표면 모델 — (방식 · 필터 · 파일) + 파일 지문 같으면 행 id 살려 고쳐 씀 · 이번 결과에 없는 옛 행만 지움
- 비용면 · 계류 메모리 기억(비용면 서명 · 계류 파일 지문 · 좌표계) · 추천 열쇠 = 서명(배열 통째 해시 없앰)
- 추천 저장본 종류(갈래 · 지형)당 최근 2벌 · 꺼내면 최근으로
- 횡단 상세 GET — 측점 파일 안 지움(valid_cross_names) · 본문 해시 ETag 304
- 시험 test_343_2_persist_keys 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:10:13 +09:00
co-authored by Claude Opus 5.5
parent cf5de56418
commit 1fcdf5aa4c
9 changed files with 706 additions and 92 deletions
@@ -15,6 +15,7 @@ LAS 없는 설계(2026-08-30 사용자 확정)의 지형 원천이자, LAS가
격자에서 뽑는 1m 등고선이 곧 2D 보간선이며, 메시(glb)는 그 격자의 표현일 뿐이다.
"""
import hashlib
import json
import logging
import time
@@ -24,6 +25,7 @@ from typing import Any
import numpy as np
from pyproj import Transformer
from B04_PreProcess.B04_PreProcess_Engine import file_stamp
from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
atomic_npz,
clip_and_compact_mesh,
@@ -36,6 +38,7 @@ from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import (
SHEET_METHOD_LABELS,
)
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import grid_spec_from_bounds
from common_util.common_util_json import atomic_write_json
from config.config_system import (
SHEET_SURFACE_GRID_M,
SHEET_SURFACE_MARGIN_M,
@@ -54,6 +57,9 @@ _CONTOUR_FILE = "도엽_등고선.geojson"
# 산출 모델 식별자 — surface_models.generation_params.source_filter 및 파일 stem에 쓴다.
SHEET_SOURCE_FILTER = "sheet"
# 계산 판 번호 — 만드는 방식이 바뀌면 올림 · 열쇠가 어긋나 옛 저장본을 다시 셈.
SHEET_SURFACE_ALGO = 1
def _load_features_metric(
processed_dir: Path, crs: str, filename: str = _CONTOUR_FILE
@@ -378,6 +384,15 @@ def _write_method_model(
vertices, faces = _preview_mesh(x_coords, y_coords, z_grid, valid_grid)
write_glb(preview_path, vertices, faces, scene)
_write_smoothed(models_dir, stem, x_coords, y_coords, z_grid, valid_grid, bounds, scene)
return _method_model_entry(project_root, models_dir, method_key)
def _method_model_entry(project_root: Path, models_dir: Path, method_key: str) -> dict[str, Any]:
"""방식 하나의 등록용 dict — 저장본 그대로 쓸 때도 같은 모양."""
source_filter = f"{SHEET_SOURCE_FILTER}_{method_key}"
stem = f"dtm_{source_filter}"
model_path = models_dir / f"{stem}.npz"
preview_path = models_dir / f"{stem}_preview.glb"
return {
"model_type": "dtm",
"source_filter": source_filter,
@@ -391,6 +406,7 @@ def _write_method_model(
"interpolation": method_key,
"interpolation_label": SHEET_METHOD_LABELS.get(method_key, method_key),
"margin_m": SHEET_SURFACE_MARGIN_M,
"file_stamp": file_stamp(model_path),
},
"layers": [
{
@@ -403,6 +419,59 @@ def _write_method_model(
}
def _sheet_key(
project_root: Path, processed_dir: Path, route_xy: np.ndarray, crs: str, method_key: str
) -> dict[str, Any] | None:
"""도엽 3D 면 열쇠 — 등고선 파일 · 노선 · 좌표계 · 격자 · 여유 · 스무딩 · 화면 상자 · 판."""
contour = processed_dir / _CONTOUR_FILE
try:
stat = contour.stat()
except OSError:
return None
scene = _scene_bounds(project_root, None) # type: ignore[arg-type] # 라이다 없으면 None
key = {
"algo": SHEET_SURFACE_ALGO,
"method": method_key,
"contour": f"{contour.name}:{stat.st_mtime_ns}:{stat.st_size}",
"route": hashlib.sha1(np.round(route_xy, 3).tobytes()).hexdigest(),
"crs": str(crs),
"grid_m": SHEET_SURFACE_GRID_M,
"margin_m": SHEET_SURFACE_MARGIN_M,
"smoothing": [
SURFACE_SMOOTHING_DTM_SIGMA_M,
SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH,
SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M,
SURFACE_MAX_PREVIEW_VERTICES,
],
"scene_bounds": None if scene is None else np.round(scene, 3).tolist(),
}
return json.loads(json.dumps(key, default=float))
def _key_path(models_dir: Path, method_key: str) -> Path:
return models_dir / f"dtm_{SHEET_SOURCE_FILTER}_{method_key}.key.json"
def _cached_method_model(
project_root: Path, models_dir: Path, method_key: str, key: dict[str, Any] | None
) -> dict[str, Any] | None:
"""열쇠가 같고 결과 파일이 있으면 등록용 dict — 아니면 None(다시 셈)."""
if key is None:
return None
entry = _method_model_entry(project_root, models_dir, method_key)
if not all((project_root / item).is_file() for item in _entry_files(entry)):
return None
try:
stored = json.loads(_key_path(models_dir, method_key).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return entry if stored == key else None
def _entry_files(entry: dict[str, Any]) -> list[str]:
return [entry["model_file_path"], *(layer["file_path"] for layer in entry["layers"])]
def build_sheet_surface_model(
project_root: Path,
processed_dir: Path,
@@ -420,6 +489,16 @@ def build_sheet_surface_model(
`route_xy`: (N, 2) 노선 정점 XY(사업지 CRS, m), `crs`: 그 좌표계(pyproj 입력 문자열).
"""
started = time.monotonic()
selected = methods or list(SHEET_SURFACE_METHODS)
# 열쇠가 같은 방식은 저장본 그대로 — 다시 셀 방식만 등고선을 읽고 굽는다.
keys = {key: _sheet_key(project_root, processed_dir, route_xy, crs, key) for key in selected}
cached = {
key: _cached_method_model(project_root, models_dir, key, keys[key]) for key in selected
}
pending = [key for key in selected if cached[key] is None]
if not pending:
logger.info("도엽 서피스: 열쇠 같음 — 저장본 그대로 (%s)", ", ".join(selected))
return [cached[key] for key in selected] # type: ignore[misc]
features = _load_features_metric(processed_dir, crs)
if not features:
return []
@@ -439,9 +518,8 @@ def build_sheet_surface_model(
# 등고 간격(m) — 마루 연장 상한의 근거. 레벨이 하나뿐이면 5m(주곡선) 폴백.
interval_m = float(np.diff(np.array(present)).min()) if len(present) > 1 else 5.0
selected = methods or list(SHEET_SURFACE_METHODS)
models: list[dict[str, Any]] = []
for method_key in selected:
built: dict[str, dict[str, Any]] = {}
for method_key in pending:
builder = SHEET_METHOD_BUILDERS.get(method_key)
if builder is None:
logger.warning("도엽 서피스: 알 수 없는 보간 방식 %s — 건너뜁니다.", method_key)
@@ -456,9 +534,13 @@ def build_sheet_surface_model(
except Exception as exc: # noqa: BLE001 — 한 방식이 죽어도 나머지는 만든다
logger.warning("도엽 서피스(%s) 생성 실패: %s", method_key, exc)
continue
# 쓰는 도중 끊기면 옛 열쇠가 새 파일 조각을 덮지 않게 먼저 지움.
_key_path(models_dir, method_key).unlink(missing_ok=True)
model = _write_method_model(project_root, models_dir, spec, surface, method_key)
if model is not None:
models.append(model)
built[method_key] = model
if keys[method_key] is not None:
atomic_write_json(_key_path(models_dir, method_key), keys[method_key])
logger.info("도엽 서피스(%s) 완료 (%.1fs)", method_key, time.monotonic() - step_started)
logger.info(
@@ -466,10 +548,10 @@ def build_sheet_surface_model(
spec.n_rows,
spec.n_cols,
len(present),
len(models),
len(built),
time.monotonic() - started,
)
return models
return [cached[key] or built[key] for key in selected if cached[key] or key in built]
def build_sheet_surface_from_route(