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
+11
View File
@@ -128,6 +128,15 @@ def _structured_stats(structured: Any, structured_path: Path) -> tuple[dict[str,
return stats, xyz
def file_stamp(path: Path | None) -> str | None:
"""결과 파일 지문 = 수정시각 ns : 크기. 없으면 None."""
try:
stat = path.stat() if path is not None else None
except OSError:
return None
return f"{stat.st_mtime_ns}:{stat.st_size}" if stat else None
def _key_matches(key_path: Path, key: Any) -> bool:
"""결과 옆 열쇠 파일이 지금 입력과 같은지."""
try:
@@ -605,6 +614,8 @@ def _collect_analysis_result(
"source_filter": filter_key,
"representation": meta.get("representation"),
"footprint_area_m2": meta.get("footprint_area_m2"),
# 파일 지문 — 같으면 DB 행 id 를 살린다(화면 주소 · 노선 연결 유지).
"file_stamp": file_stamp(model_path),
},
"layers": layers,
}
@@ -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(
+135 -58
View File
@@ -337,19 +337,114 @@ async def update_project_status(
)
async def delete_project_surface_models(connection: aiomysql.Connection, project_id: UUID) -> int:
"""프로젝트의 기존 지표면 모델 행을 모두 제거한다.
def _params_dict(value: Any) -> dict[str, Any]:
if isinstance(value, (str, bytes)):
try:
value = json.loads(value)
except ValueError:
return {}
return value if isinstance(value, dict) else {}
모든 분석 세대가 동일 파일 경로를 재사용하므로 재분석 시 구세대 행을
남기면 존재하지 않는 파일을 가리키게 된다. terrain_layers는 FK CASCADE,
routes.surface_model_id는 FK SET NULL로 함께 정리된다.
"""
def _model_key(model_type: Any, params: dict[str, Any], model_file_path: Any) -> tuple:
"""같은 모델인지 가리는 열쇠 — (방식, 필터, 파일)."""
return (str(model_type), str(params.get("source_filter")), str(model_file_path))
async def _existing_models(
connection: aiomysql.Connection, project_id: UUID
) -> dict[tuple, tuple[int, Any]]:
"""기존 지표면 행 — 열쇠 → (id, 파일 지문)."""
async with connection.cursor() as cursor:
await cursor.execute(
"DELETE FROM surface_models WHERE project_id = %s",
"SELECT id, model_type, model_file_path, generation_params "
"FROM surface_models WHERE project_id = %s ORDER BY id",
(str(project_id),),
)
return int(cursor.rowcount)
rows = await cursor.fetchall()
existing: dict[tuple, tuple[int, Any]] = {}
for model_id, model_type, model_file_path, raw_params in rows:
params = _params_dict(raw_params)
existing[_model_key(model_type, params, model_file_path)] = (
int(model_id),
params.get("file_stamp"),
)
return existing
async def _upsert_surface_model(
connection: aiomysql.Connection,
existing: dict[tuple, tuple[int, Any]],
*,
project_id: UUID,
model: dict[str, Any],
source_file_id: int | None,
processed_cloud_id: int | None,
crs_epsg: int | None,
) -> int:
"""파일 지문이 같은 옛 행이면 id 를 살려 고쳐 씀 — 화면 주소 · 노선 연결이 그대로.
지문이 다르거나 없으면 옛 행을 지우고 새로 넣는다(새 지표면 = 노선 연결도 끊김).
"""
params = model["generation_params"] or {}
hit = existing.pop(_model_key(model["model_type"], params, model["model_file_path"]), None)
stamp = params.get("file_stamp")
if hit is not None and stamp and hit[1] == stamp:
model_id = hit[0]
async with connection.cursor() as cursor:
await cursor.execute(
"""
UPDATE surface_models
SET source_file_id = %s, processed_cloud_id = %s, status = 'COMPLETE',
crs_epsg = %s, resolution_m = %s, generation_params = %s,
completed_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(
source_file_id,
processed_cloud_id,
crs_epsg,
model["resolution_m"],
json.dumps(params, ensure_ascii=False),
model_id,
),
)
await cursor.execute(
"DELETE FROM terrain_layers WHERE surface_model_id = %s", (model_id,)
)
else:
# 같은 파일을 가리키는 옛 행은 걷어 낸다 — 열쇠 모양이 옛것이어도 겹치지 않게.
async with connection.cursor() as cursor:
if model["model_file_path"]:
await cursor.execute(
"DELETE FROM surface_models WHERE project_id = %s AND model_file_path = %s",
(str(project_id), model["model_file_path"]),
)
elif hit is not None:
await cursor.execute("DELETE FROM surface_models WHERE id = %s", (hit[0],))
model_id = await create_surface_model(
connection,
project_id=project_id,
model_type=model["model_type"],
source_file_id=source_file_id,
processed_cloud_id=processed_cloud_id,
crs_epsg=crs_epsg,
resolution_m=model["resolution_m"],
model_file_path=model["model_file_path"],
generation_params=model["generation_params"],
)
for layer in model.get("layers", []):
await create_terrain_layer(
connection,
surface_model_id=model_id,
layer_name=layer["layer_name"],
geometry_type=layer["geometry_type"],
layer_file_path=layer["file_path"],
file_format=layer["file_format"],
file_size_mb=None,
statistics=None,
)
return model_id
async def save_surface_analysis_to_db(
@@ -362,11 +457,12 @@ async def save_surface_analysis_to_db(
) -> list[int]:
"""WF1 분석 결과를 DB에 저장한다 (기존 모델 행은 교체).
파일 지문이 같은 모델은 id 를 살리고, 이번 결과에 없는 옛 행은 지운다.
이 함수는 트랜잭션을 시작하거나 종료하지 않는다. 호출자는 같은 커넥션에서
begin/commit/rollback을 한 번만 수행해야 한다.
"""
input_file = await get_input_file(connection, project_id, input_file_id)
await delete_project_surface_models(connection, project_id)
existing = await _existing_models(connection, project_id)
processed = analysis_result["processed"]
processed_cloud_id = await create_processed_point_cloud(
connection,
@@ -384,28 +480,26 @@ async def save_surface_analysis_to_db(
)
surface_model_ids: list[int] = []
for model in analysis_result["models"]:
model_id = await create_surface_model(
connection,
project_id=project_id,
model_type=model["model_type"],
source_file_id=input_file_id,
processed_cloud_id=processed_cloud_id,
crs_epsg=input_file["crs_epsg"],
resolution_m=model["resolution_m"],
model_file_path=model["model_file_path"],
generation_params=model["generation_params"],
)
surface_model_ids.append(model_id)
for layer in model["layers"]:
await create_terrain_layer(
surface_model_ids.append(
await _upsert_surface_model(
connection,
surface_model_id=model_id,
layer_name=layer["layer_name"],
geometry_type=layer["geometry_type"],
layer_file_path=layer["file_path"],
file_format=layer["file_format"],
file_size_mb=None,
statistics=None,
existing,
project_id=project_id,
model=model,
source_file_id=input_file_id,
processed_cloud_id=processed_cloud_id,
crs_epsg=input_file["crs_epsg"],
)
)
# 이번 결과에 없는 옛 행 — terrain_layers 는 CASCADE · routes.surface_model_id 는 SET NULL.
stale_ids = [model_id for model_id, _ in existing.values()]
if stale_ids:
async with connection.cursor() as cursor:
await cursor.execute(
"DELETE FROM surface_models WHERE id IN ("
+ ", ".join(["%s"] * len(stale_ids))
+ ")",
tuple(stale_ids),
)
return surface_model_ids
@@ -433,36 +527,19 @@ async def save_sheet_surface_models(
row = await cursor.fetchone()
source_file_id, processed_cloud_id, crs_epsg = row if row else (None, None, None)
# 같은 파일 · 같은 지문이면 id 를 살린다 — 다른 행은 건드리지 않는다.
existing = await _existing_models(connection, project_id)
model_ids: list[int] = []
for model in models:
model_path = model.get("model_file_path")
if model_path:
async with connection.cursor() as cursor:
await cursor.execute(
"DELETE FROM surface_models WHERE project_id = %s AND model_file_path = %s",
(str(project_id), model_path),
)
model_id = await create_surface_model(
connection,
project_id=project_id,
model_type=model["model_type"],
source_file_id=source_file_id,
processed_cloud_id=processed_cloud_id,
crs_epsg=crs_epsg,
resolution_m=model["resolution_m"],
model_file_path=model_path,
generation_params=model["generation_params"],
)
model_ids.append(model_id)
for layer in model.get("layers", []):
await create_terrain_layer(
model_ids.append(
await _upsert_surface_model(
connection,
surface_model_id=model_id,
layer_name=layer["layer_name"],
geometry_type=layer["geometry_type"],
layer_file_path=layer["file_path"],
file_format=layer["file_format"],
file_size_mb=None,
statistics=None,
existing,
project_id=project_id,
model=model,
source_file_id=source_file_id,
processed_cloud_id=processed_cloud_id,
crs_epsg=crs_epsg,
)
)
return model_ids