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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -109,12 +109,19 @@ MODE_WEIGHTS = {
|
||||
# 후보 묶음은 갈래와 무관 — 같은 입력이면 다시 짜지 않음(최근 몇 벌만 · 프로세스 메모리)
|
||||
POOL_CACHE_SIZE = 4
|
||||
_POOLS: dict[bytes, Pool] = {}
|
||||
# 비용면 · 계류 기억(요청 사이) — 열쇠 = 프로젝트 · 비용면 서명 · 계류 파일 지문 · 좌표계
|
||||
TERRAIN_CACHE_SIZE = 4
|
||||
_TERRAINS: dict[str, tuple[Terrain, list[list[tuple[float, float]]]]] = {}
|
||||
|
||||
|
||||
def load_terrain_and_streams(
|
||||
project_root: Path, selection: dict[str, Any]
|
||||
) -> tuple[Terrain, list[list[tuple[float, float]]]]:
|
||||
"""지반 격자(탐색 비용면 캐시 그대로)와 이격을 따질 계류(사업지 좌표계)를 읽는다."""
|
||||
"""지반 격자(탐색 비용면 캐시 그대로)와 이격을 따질 계류(사업지 좌표계)를 읽는다.
|
||||
|
||||
같은 입력(비용면 서명 · 계류 파일 지문 · 좌표계)이면 메모리 기억을 그대로 돌려준다 —
|
||||
점 놓을 때마다 npz · geojson 을 다시 읽지 않는다. 돌려준 배열은 고치지 않는다.
|
||||
"""
|
||||
from pyproj import Transformer
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import (
|
||||
@@ -122,20 +129,49 @@ def load_terrain_and_streams(
|
||||
_load_features,
|
||||
_reproject_features,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Solver import _MODELS_SUBDIR, _load_or_build_cost_surface
|
||||
from B05_Profile.B05_Profile_Engine_Solver import (
|
||||
_MODELS_SUBDIR,
|
||||
_cost_surface_signature,
|
||||
_load_or_build_cost_surface,
|
||||
)
|
||||
from common_util.common_util_crs import resolve_project_crs
|
||||
|
||||
x, y, z, valid, *_ = _load_or_build_cost_surface(
|
||||
project_root,
|
||||
project_root / _MODELS_SUBDIR,
|
||||
str(selection.get("source_filter")),
|
||||
str(selection.get("method") or "dtm"),
|
||||
bool(selection.get("smooth")),
|
||||
filter_key = str(selection.get("source_filter"))
|
||||
method = str(selection.get("method") or "dtm")
|
||||
smooth = bool(selection.get("smooth"))
|
||||
models_dir = project_root / _MODELS_SUBDIR
|
||||
stream_path = project_root / "B04_PreProcess" / "processed" / STREAM_FILE
|
||||
crs = resolve_project_crs(project_root)
|
||||
try:
|
||||
stat = stream_path.stat()
|
||||
stream_stamp = f"{stat.st_mtime_ns}:{stat.st_size}"
|
||||
except OSError:
|
||||
stream_stamp = "-"
|
||||
signature = "|".join(
|
||||
(
|
||||
filter_key,
|
||||
_cost_surface_signature(models_dir, filter_key, method, smooth),
|
||||
stream_stamp,
|
||||
str(crs),
|
||||
)
|
||||
)
|
||||
to_metric = Transformer.from_crs("EPSG:4326", resolve_project_crs(project_root), always_xy=True)
|
||||
features = _load_features(project_root / "B04_PreProcess" / "processed", STREAM_FILE)
|
||||
memo_key = f"{project_root}|{signature}"
|
||||
if memo_key in _TERRAINS:
|
||||
return _TERRAINS[memo_key]
|
||||
|
||||
x, y, z, valid, *_ = _load_or_build_cost_surface(
|
||||
project_root, models_dir, filter_key, method, smooth
|
||||
)
|
||||
to_metric = Transformer.from_crs("EPSG:4326", crs, always_xy=True)
|
||||
features = _load_features(stream_path.parent, STREAM_FILE)
|
||||
streams = stream_lines(_reproject_features(features, to_metric))
|
||||
return Terrain(np.asarray(x), np.asarray(y), np.asarray(z), np.asarray(valid)), streams
|
||||
terrain = Terrain(
|
||||
np.asarray(x), np.asarray(y), np.asarray(z), np.asarray(valid), signature=signature
|
||||
)
|
||||
while len(_TERRAINS) >= TERRAIN_CACHE_SIZE:
|
||||
_TERRAINS.pop(next(iter(_TERRAINS)))
|
||||
_TERRAINS[memo_key] = (terrain, streams)
|
||||
return terrain, streams
|
||||
|
||||
|
||||
def _choice_path(project_root: Path) -> Path:
|
||||
@@ -378,8 +414,16 @@ def _pool_key(
|
||||
streams: list[list[tuple[float, float]]],
|
||||
*numbers: float,
|
||||
) -> bytes:
|
||||
"""후보 묶음을 가를 입력 지문 — 지형 · 예상노선 · 계류 · 제약 값."""
|
||||
"""후보 묶음을 가를 입력 지문 — 지형 · 예상노선 · 계류 · 제약 값.
|
||||
|
||||
지형에 입력 지문(`signature` — 비용면 서명 + 계류 파일)이 있으면 배열 통째 해시 대신 씀.
|
||||
"""
|
||||
digest = hashlib.blake2b(digest_size=16)
|
||||
if terrain.signature:
|
||||
digest.update(terrain.signature.encode())
|
||||
digest.update(np.ascontiguousarray(np.asarray(expected)).tobytes())
|
||||
digest.update(repr(numbers).encode())
|
||||
return digest.digest()
|
||||
for array in (terrain.x, terrain.y, terrain.z, terrain.valid, np.asarray(expected)):
|
||||
digest.update(np.ascontiguousarray(array).tobytes())
|
||||
digest.update(repr((streams, numbers)).encode())
|
||||
|
||||
@@ -68,6 +68,8 @@ class Terrain:
|
||||
y: np.ndarray # 행 좌표(오름차순)
|
||||
z: np.ndarray # [행, 열] 표고
|
||||
valid: np.ndarray # [행, 열] 지형 있음
|
||||
# 지형 · 계류 입력 지문(비용면 서명 + 계류 파일) — 있으면 열쇠가 배열 통째 해시를 대신함
|
||||
signature: str | None = None
|
||||
|
||||
|
||||
class NoRoute(ValueError):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""계획노선 추천 결과 영구 저장(PLAN 327 · 요구 224-3).
|
||||
|
||||
같은 입력이면 같은 결과 — 셈한 추천을 입력 지문 키로 프로젝트 폴더에 남기고, 같은 입력이 오면
|
||||
셈하지 않고 꺼낸다. 바뀐 입력만 새로 셈해 더한다(덮어쓰기 없음 · 상한 없음).
|
||||
셈하지 않고 꺼낸다. 바뀐 입력만 새로 셈해 더한다(덮어쓰기 없음 · 종류마다 최근 `KEEP_PER_KIND` 벌).
|
||||
|
||||
층 = 서버 계산 캐시(데이터 3층 밖 · 사용자 편집값 아님 — 자동저장 금지와 무관). 자리
|
||||
`B05_Profile/route_recommend/<키>.json` — initial_snapshot 대상(`B05_Profile/route`) 밖이라
|
||||
@@ -33,6 +33,8 @@ from common_util.common_util_json import atomic_write_json
|
||||
|
||||
ALGO_VERSION = 2
|
||||
STORE_DIR = "B05_Profile/route_recommend"
|
||||
# 종류(갈래 · 지형) 마다 남길 벌 수 — 꺼낼 때 수정시각을 새로 해 최근에 쓴 것이 남음
|
||||
KEEP_PER_KIND = 2
|
||||
# 업로드 체인 끝에 미리 셈할 기본 다섯 알약(화면 차례) · 추적은 묶음이 선 뒤라 `similar` 가 채워짐
|
||||
DEFAULT_MODES = ("gradeline", "earthwork", "gentle", "weighted", "follow")
|
||||
|
||||
@@ -85,12 +87,17 @@ def is_default(
|
||||
|
||||
def read_stored(project_root: Path, key: str) -> dict[str, Any] | None:
|
||||
"""저장본 한 벌 — 없거나 못 읽거나 판이 다르면 None."""
|
||||
path = store_dir(project_root) / f"{key}.json"
|
||||
try:
|
||||
data = json.loads((store_dir(project_root) / f"{key}.json").read_text(encoding="utf-8"))
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(data, dict) or data.get("version") != ALGO_VERSION:
|
||||
return None
|
||||
try:
|
||||
path.touch() # 최근에 쓴 것 = 정리 때 남김
|
||||
except OSError:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
@@ -118,9 +125,32 @@ def write_stored(
|
||||
"result": result,
|
||||
},
|
||||
)
|
||||
prune_stored(project_root, request)
|
||||
return True
|
||||
|
||||
|
||||
def _kind(request: Any) -> tuple[Any, Any]:
|
||||
request = request if isinstance(request, dict) else {}
|
||||
return request.get("mode"), request.get("terrain")
|
||||
|
||||
|
||||
def prune_stored(project_root: Path, request: dict[str, Any]) -> int:
|
||||
"""같은 종류(갈래 · 지형) 저장본을 최근 `KEEP_PER_KIND` 벌만 남기고 지운다. 지운 수."""
|
||||
kind = _kind(request)
|
||||
same: list[tuple[int, Path]] = []
|
||||
for path in store_dir(project_root).glob("*.json"):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if _kind(data.get("request")) == kind:
|
||||
same.append((path.stat().st_mtime_ns, path))
|
||||
except (OSError, ValueError, AttributeError):
|
||||
continue
|
||||
same.sort(reverse=True)
|
||||
for _, path in same[KEEP_PER_KIND:]:
|
||||
path.unlink(missing_ok=True)
|
||||
return max(0, len(same) - KEEP_PER_KIND)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def pool_lock(pool: str) -> AsyncIterator[None]:
|
||||
# ponytail: 잠금은 지우지 않음 — 입력 묶음 수만큼(작음) · 다중 프로세스면 파일 잠금으로
|
||||
|
||||
@@ -141,18 +141,27 @@ def find_cross_file(cross_dir: Path, chainage_m: float) -> Path:
|
||||
return path if path.exists() or not legacy.exists() else legacy
|
||||
|
||||
|
||||
def prune_stale_cross_files(cross_dir: Path, stations: list[Any]) -> set[str]:
|
||||
"""stations에 없는 잔재 cross_*.json을 삭제하고 유효 파일명 집합을 반환한다.
|
||||
def valid_cross_names(cross_dir: Path, stations: list[Any]) -> set[str]:
|
||||
"""stations 에 맞는 cross_*.json 파일명 집합 — 읽기 전용(지우지 않음 · GET 이 씀).
|
||||
|
||||
측점 정보가 비어 있으면 오삭제를 피하기 위해 아무것도 지우지 않고
|
||||
빈 집합을 반환한다(호출부는 빈 집합이면 필터를 생략한다).
|
||||
측점 정보가 비면 빈 집합(호출부는 빈 집합이면 필터를 생략한다).
|
||||
"""
|
||||
# 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 찾기 규칙(find_cross_file)으로 가려야 안 지워짐
|
||||
valid = {
|
||||
# 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 찾기 규칙(find_cross_file)으로 가림
|
||||
return {
|
||||
find_cross_file(cross_dir, station["chainage_m"]).name
|
||||
for station in stations
|
||||
if isinstance(station, dict) and isinstance(station.get("chainage_m"), (int, float))
|
||||
}
|
||||
|
||||
|
||||
def prune_stale_cross_files(cross_dir: Path, stations: list[Any]) -> set[str]:
|
||||
"""stations에 없는 잔재 cross_*.json을 삭제하고 유효 파일명 집합을 반환한다.
|
||||
|
||||
쓰는 쪽(측점 만들기 · 측점 목록 고침)만 부른다 — 조회(GET)는 `valid_cross_names`.
|
||||
측점 정보가 비어 있으면 오삭제를 피하기 위해 아무것도 지우지 않고
|
||||
빈 집합을 반환한다(호출부는 빈 집합이면 필터를 생략한다).
|
||||
"""
|
||||
valid = valid_cross_names(cross_dir, stations)
|
||||
if not valid:
|
||||
return valid
|
||||
for path in cross_dir.glob("cross_*.json"):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""B06 종횡단 생성 FastAPI 라우터."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -8,15 +9,15 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
prune_stale_cross_files,
|
||||
relabel_pipe_stations,
|
||||
run_section_generation,
|
||||
valid_cross_names,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||
from B06_Section.B06_Section_Engine_Attach import attach_saved_shapes
|
||||
@@ -85,6 +86,7 @@ from B06_Section.B06_Section_Schema import (
|
||||
)
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_http_cache import CACHE_CONTROL
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_workflow_state import get_workflow_state
|
||||
@@ -266,7 +268,8 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
|
||||
|
||||
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
|
||||
stations = longitudinal.get("stations") if isinstance(longitudinal, dict) else None
|
||||
valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else [])
|
||||
# 조회는 지우지 않음 — 잔재는 걸러 읽기만(지우기는 측점을 쓰는 쪽 몫)
|
||||
valid_names = valid_cross_names(cross_dir, stations if isinstance(stations, list) else [])
|
||||
cross_sections = [
|
||||
json.loads(path.read_text(encoding="utf-8"))
|
||||
for path in sorted(cross_dir.glob("cross_*.json"))
|
||||
@@ -282,6 +285,15 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
|
||||
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
|
||||
|
||||
|
||||
def _etag_json_response(request: Request, body: bytes) -> Response:
|
||||
"""본문 해시 ETag JSON 응답 — 브라우저가 같은 ETag 를 들고 오면 304 만."""
|
||||
etag = f'"{hashlib.blake2b(body, digest_size=16).hexdigest()}"'
|
||||
headers = {"ETag": etag, "Cache-Control": CACHE_CONTROL}
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers=headers)
|
||||
return Response(content=body, media_type="application/json", headers=headers)
|
||||
|
||||
|
||||
def _read_balloon_offsets(row: dict[str, Any]) -> dict[str, list[float]] | None:
|
||||
"""확정 시 저장해 둔 유토곡선 balloon 위치를 꺼낸다. 없거나 형태가 깨졌으면 None."""
|
||||
data = row.get("data")
|
||||
@@ -307,9 +319,12 @@ def _read_balloon_offsets(row: dict[str, Any]) -> dict[str, list[float]] | None:
|
||||
|
||||
@router.get("/{project_id}/sections/{route_id}/detail", response_model=SectionDetailResponse)
|
||||
async def get_section_detail(
|
||||
project_id: UUID, route_id: int
|
||||
) -> SectionDetailResponse | JSONResponse:
|
||||
"""경로의 SVG 렌더링용 종단·횡단 원시 샘플을 반환한다."""
|
||||
project_id: UUID, route_id: int, request: Request
|
||||
) -> SectionDetailResponse | JSONResponse | Response:
|
||||
"""경로의 SVG 렌더링용 종단·횡단 원시 샘플을 반환한다.
|
||||
|
||||
ETag = 응답 본문 해시 — 내용이 같으면 304(1.5 MB 다시 안 받음 · 브라우저 HTTP 캐시).
|
||||
"""
|
||||
try:
|
||||
# 서로 기다릴 이유가 없는 읽기 셋 — 원격 DB 라 순차로 내면 왕복이 그대로 더해진다
|
||||
# (질의 하나 약 12ms, 2026-09-06 실측). 같이 보내 가장 느린 하나의 시간만 쓴다.
|
||||
@@ -350,7 +365,10 @@ async def get_section_detail(
|
||||
project_root,
|
||||
standard,
|
||||
)
|
||||
return SectionDetailResponse(**detail, balloon_offsets=_read_balloon_offsets(longitudinal))
|
||||
body = SectionDetailResponse(
|
||||
**detail, balloon_offsets=_read_balloon_offsets(longitudinal)
|
||||
).model_dump_json()
|
||||
return _etag_json_response(request, body.encode("utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""343-2 도엽 3D 면 열쇠 · 지표면 모델 id 유지 · 비용면 · 추천 노선 열쇠 · 횡단 상세 ETag.
|
||||
|
||||
같은 입력 두 번째 = 다시 안 셈 · 열쇠 바뀌면 다시 셈 · 지울 때.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from pyproj import Transformer
|
||||
|
||||
import B04_PreProcess.B04_PreProcess_Engine_SheetSurface as sheet
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import SHEET_METHOD_BUILDERS
|
||||
|
||||
EPSG = 5186
|
||||
X0, Y0 = 200000.0, 500000.0
|
||||
ROUTE = np.array([[X0 - 50.0, Y0], [X0 + 50.0, Y0]], dtype=np.float64)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sheet_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
|
||||
processed = tmp_path / "B04_PreProcess" / "processed"
|
||||
models = tmp_path / "B04_PreProcess" / "models"
|
||||
processed.mkdir(parents=True)
|
||||
models.mkdir(parents=True)
|
||||
to_wgs84 = Transformer.from_crs(f"EPSG:{EPSG}", "EPSG:4326", always_xy=True)
|
||||
features = []
|
||||
for half, elev in ((450.0, 100.0), (300.0, 110.0), (150.0, 120.0)):
|
||||
ring = [
|
||||
list(to_wgs84.transform(x, y))
|
||||
for x, y in (
|
||||
(X0 - half, Y0 - half),
|
||||
(X0 + half, Y0 - half),
|
||||
(X0 + half, Y0 + half),
|
||||
(X0 - half, Y0 + half),
|
||||
(X0 - half, Y0 - half),
|
||||
)
|
||||
]
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {"등고수치": elev},
|
||||
"geometry": {"type": "LineString", "coordinates": ring},
|
||||
}
|
||||
)
|
||||
(processed / "도엽_등고선.geojson").write_text(
|
||||
json.dumps({"type": "FeatureCollection", "features": features}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
calls: list[str] = []
|
||||
real = SHEET_METHOD_BUILDERS["tin_sheet"]
|
||||
|
||||
def counting(*args, **kwargs): # type: ignore[no-untyped-def]
|
||||
calls.append("tin_sheet")
|
||||
return real(*args, **kwargs)
|
||||
|
||||
monkeypatch.setitem(SHEET_METHOD_BUILDERS, "tin_sheet", counting)
|
||||
return {"root": tmp_path, "processed": processed, "models": models, "calls": calls}
|
||||
|
||||
|
||||
def _build(project: dict, route: np.ndarray = ROUTE) -> list[dict]:
|
||||
return sheet.build_sheet_surface_model(
|
||||
project["root"], project["processed"], project["models"], route, EPSG, ["tin_sheet"]
|
||||
)
|
||||
|
||||
|
||||
def test_도엽_3D_면_같은_입력_두번째는_안_셈(sheet_project: dict) -> None:
|
||||
first = _build(sheet_project)
|
||||
assert sheet_project["calls"] == ["tin_sheet"]
|
||||
assert (sheet_project["models"] / "dtm_sheet_tin_sheet.key.json").is_file()
|
||||
started = time.perf_counter()
|
||||
second = _build(sheet_project)
|
||||
assert time.perf_counter() - started < 0.5
|
||||
assert sheet_project["calls"] == ["tin_sheet"]
|
||||
assert second == first
|
||||
assert first[0]["generation_params"]["file_stamp"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["route", "contour", "algo", "key_gone", "file_gone"])
|
||||
def test_도엽_3D_면_열쇠가_바뀌면_다시_셈(
|
||||
sheet_project: dict, monkeypatch: pytest.MonkeyPatch, change: str
|
||||
) -> None:
|
||||
_build(sheet_project)
|
||||
route = ROUTE
|
||||
if change == "route":
|
||||
route = ROUTE + np.array([5.0, 0.0])
|
||||
elif change == "contour":
|
||||
path = sheet_project["processed"] / "도엽_등고선.geojson"
|
||||
stat = path.stat()
|
||||
os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 5_000_000_000))
|
||||
elif change == "algo":
|
||||
monkeypatch.setattr(sheet, "SHEET_SURFACE_ALGO", sheet.SHEET_SURFACE_ALGO + 1)
|
||||
elif change == "key_gone":
|
||||
(sheet_project["models"] / "dtm_sheet_tin_sheet.key.json").unlink()
|
||||
else:
|
||||
(sheet_project["models"] / "dtm_sheet_tin_sheet_preview.glb").unlink()
|
||||
_build(sheet_project, route)
|
||||
assert sheet_project["calls"] == ["tin_sheet", "tin_sheet"]
|
||||
|
||||
|
||||
# ── 지표면 모델 id 유지(R2) ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
def __init__(self, db: "_FakeDb") -> None:
|
||||
self.db = db
|
||||
self.rowcount = 0
|
||||
self.lastrowid = 0
|
||||
self._rows: list[tuple] = []
|
||||
|
||||
async def __aenter__(self) -> "_FakeCursor":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
async def execute(self, sql: str, params: tuple = ()) -> None:
|
||||
text = " ".join(sql.split())
|
||||
self.db.log.append(text)
|
||||
if text.startswith("SELECT id, model_type"):
|
||||
self._rows = [
|
||||
(row["id"], row["model_type"], row["path"], json.dumps(row["params"]))
|
||||
for row in self.db.models.values()
|
||||
]
|
||||
elif text.startswith("INSERT INTO surface_models"):
|
||||
self.db.next_id += 1
|
||||
self.lastrowid = self.db.next_id
|
||||
self.db.models[self.lastrowid] = {
|
||||
"id": self.lastrowid,
|
||||
"model_type": params[1],
|
||||
"path": params[7],
|
||||
"params": json.loads(params[8]),
|
||||
}
|
||||
elif text.startswith("UPDATE surface_models"):
|
||||
self.db.models[params[-1]]["params"] = json.loads(params[4])
|
||||
elif text.startswith("DELETE FROM surface_models WHERE id IN"):
|
||||
for model_id in params:
|
||||
self.db.models.pop(model_id, None)
|
||||
elif text.startswith("DELETE FROM surface_models WHERE id ="):
|
||||
self.db.models.pop(params[0], None)
|
||||
elif text.startswith("DELETE FROM surface_models WHERE project_id"):
|
||||
for model_id in [k for k, v in self.db.models.items() if v["path"] == params[1]]:
|
||||
self.db.models.pop(model_id)
|
||||
elif text.startswith("INSERT INTO terrain_layers"):
|
||||
self.db.next_id += 1
|
||||
self.lastrowid = self.db.next_id
|
||||
|
||||
async def fetchall(self) -> list[tuple]:
|
||||
return self._rows
|
||||
|
||||
async def fetchone(self) -> tuple | None:
|
||||
return None
|
||||
|
||||
|
||||
class _FakeDb:
|
||||
def __init__(self) -> None:
|
||||
self.log: list[str] = []
|
||||
self.models: dict[int, dict] = {}
|
||||
self.next_id = 100
|
||||
|
||||
def cursor(self, *_: object) -> _FakeCursor:
|
||||
return _FakeCursor(self)
|
||||
|
||||
|
||||
def _model(path: str, stamp: str) -> dict:
|
||||
return {
|
||||
"model_type": "dtm",
|
||||
"model_file_path": path,
|
||||
"resolution_m": 1.0,
|
||||
"generation_params": {"source_filter": "csf", "file_stamp": stamp},
|
||||
"layers": [
|
||||
{
|
||||
"layer_name": "dtm_csf_preview",
|
||||
"geometry_type": "MESH",
|
||||
"file_path": "B04_PreProcess/models/dtm_csf_preview.glb",
|
||||
"file_format": "glb",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _save(db: _FakeDb, models: list[dict]) -> list[int]:
|
||||
from B04_PreProcess import B04_PreProcess_Repository as repo
|
||||
|
||||
async def fake_input(*_a): # type: ignore[no-untyped-def]
|
||||
return {"crs_epsg": 5186}
|
||||
|
||||
async def fake_cloud(*_a, **_k): # type: ignore[no-untyped-def]
|
||||
return 9
|
||||
|
||||
repo_input, repo_cloud = repo.get_input_file, repo.create_processed_point_cloud
|
||||
repo.get_input_file, repo.create_processed_point_cloud = fake_input, fake_cloud
|
||||
try:
|
||||
return asyncio.run(
|
||||
repo.save_surface_analysis_to_db(
|
||||
db, # type: ignore[arg-type]
|
||||
project_id="00000000-0000-0000-0000-000000000001", # type: ignore[arg-type]
|
||||
input_file_id=1,
|
||||
analysis_result={
|
||||
"processed": {
|
||||
"processed_file_path": "B04_PreProcess/processed/structured.npz",
|
||||
"converted_file_path": None,
|
||||
"point_count": 1,
|
||||
"bounds": {},
|
||||
"statistics": {},
|
||||
},
|
||||
"models": models,
|
||||
},
|
||||
source_filters=["csf"],
|
||||
)
|
||||
)
|
||||
finally:
|
||||
repo.get_input_file, repo.create_processed_point_cloud = repo_input, repo_cloud
|
||||
|
||||
|
||||
def test_파일_지문이_같으면_모델_id_유지() -> None:
|
||||
db = _FakeDb()
|
||||
path = "B04_PreProcess/models/dtm_csf.npz"
|
||||
first = _save(db, [_model(path, "1:2"), _model("B04_PreProcess/models/tin_csf.npz", "3:4")])
|
||||
second = _save(db, [_model(path, "1:2")])
|
||||
assert second == [first[0]]
|
||||
# 이번 결과에 없는 옛 행은 지움 · 같은 파일 행은 하나
|
||||
assert list(db.models) == [first[0]]
|
||||
third = _save(db, [_model(path, "9:9")])
|
||||
assert third != second and list(db.models) == third
|
||||
|
||||
|
||||
def test_도엽_한_방식_요청도_id_유지() -> None:
|
||||
from B04_PreProcess import B04_PreProcess_Repository as repo
|
||||
|
||||
db = _FakeDb()
|
||||
path = "B04_PreProcess/models/dtm_sheet_tin_sheet.npz"
|
||||
entry = {**_model(path, "5:6"), "generation_params": {"source_filter": "sheet_tin_sheet"}}
|
||||
entry["generation_params"]["file_stamp"] = "5:6"
|
||||
first = asyncio.run(repo.save_sheet_surface_models(db, "p", [entry])) # type: ignore[arg-type]
|
||||
second = asyncio.run(repo.save_sheet_surface_models(db, "p", [entry])) # type: ignore[arg-type]
|
||||
assert first == second and len(db.models) == 1
|
||||
|
||||
|
||||
# ── 비용면 · 계류 메모리 기억(R17) · 추천 노선 열쇠(R18) ──────────────────────────
|
||||
|
||||
|
||||
def test_비용면_계류_메모리_기억(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed
|
||||
import B05_Profile.B05_Profile_Engine_RouteInitial as engine
|
||||
import B05_Profile.B05_Profile_Engine_Solver as solver
|
||||
import common_util.common_util_crs as crs
|
||||
|
||||
loads: list[int] = []
|
||||
signature = {"value": "sig-1"}
|
||||
grid = np.arange(4.0)
|
||||
|
||||
def fake_load(*_a): # type: ignore[no-untyped-def]
|
||||
loads.append(1)
|
||||
return grid, grid, np.zeros((4, 4)), np.ones((4, 4), bool), None, None, 1.0
|
||||
|
||||
monkeypatch.setattr(engine, "_TERRAINS", {})
|
||||
monkeypatch.setattr(solver, "_load_or_build_cost_surface", fake_load)
|
||||
monkeypatch.setattr(solver, "_cost_surface_signature", lambda *_a: signature["value"])
|
||||
monkeypatch.setattr(watershed, "_load_features", lambda *_a: [])
|
||||
monkeypatch.setattr(crs, "resolve_project_crs", lambda _root: "EPSG:5186")
|
||||
selection = {"source_filter": "csf", "method": "dtm", "smooth": False}
|
||||
terrain, _ = engine.load_terrain_and_streams(tmp_path, selection)
|
||||
again, _ = engine.load_terrain_and_streams(tmp_path, selection)
|
||||
assert loads == [1] and again is terrain and terrain.signature
|
||||
signature["value"] = "sig-2"
|
||||
changed, _ = engine.load_terrain_and_streams(tmp_path, selection)
|
||||
assert loads == [1, 1] and changed.signature != terrain.signature
|
||||
# 추천 열쇠 = 서명 — 배열 통째 해시 안 함 · 서명이 다르면 다른 열쇠
|
||||
key_a = engine._pool_key([(0.0, 0.0), (3.0, 3.0)], terrain, [], 0.14)
|
||||
key_b = engine._pool_key([(0.0, 0.0), (3.0, 3.0)], changed, [], 0.14)
|
||||
assert key_a != key_b
|
||||
assert key_a == engine._pool_key([(0.0, 0.0), (3.0, 3.0)], terrain, [[(9.0, 9.0)]], 0.14)
|
||||
|
||||
|
||||
def test_추천_저장본_종류당_최근_두_벌(tmp_path: Path) -> None:
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import (
|
||||
KEEP_PER_KIND,
|
||||
read_stored,
|
||||
store_dir,
|
||||
write_stored,
|
||||
)
|
||||
|
||||
assert KEEP_PER_KIND == 2
|
||||
request = {"mode": "earthwork", "terrain": None}
|
||||
write_stored(tmp_path, "a", "p", request, {"n": 1}, True)
|
||||
time.sleep(0.02)
|
||||
write_stored(tmp_path, "b", "p", request, {"n": 2}, False)
|
||||
write_stored(tmp_path, "g", "p", {"mode": "gentle", "terrain": None}, {}, True)
|
||||
time.sleep(0.02)
|
||||
assert read_stored(tmp_path, "a") is not None # 꺼내면 최근으로
|
||||
time.sleep(0.02)
|
||||
write_stored(tmp_path, "c", "p", request, {"n": 3}, False)
|
||||
names = sorted(path.stem for path in store_dir(tmp_path).glob("*.json"))
|
||||
assert names == ["a", "c", "g"]
|
||||
|
||||
|
||||
# ── 횡단 상세 — GET 은 안 지움 · ETag(R26) ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_횡단_상세_조회는_파일을_지우지_않음(tmp_path: Path) -> None:
|
||||
from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files, valid_cross_names
|
||||
|
||||
for name in ("cross_00000m.json", "cross_00020m.json", "cross_00040m.json"):
|
||||
(tmp_path / name).write_text("{}", encoding="utf-8")
|
||||
stations = [{"chainage_m": 0.0}, {"chainage_m": 20.0}]
|
||||
names = valid_cross_names(tmp_path, stations)
|
||||
assert len(names) == 2 and (tmp_path / "cross_00040m.json").is_file()
|
||||
assert prune_stale_cross_files(tmp_path, stations) == names
|
||||
assert not (tmp_path / "cross_00040m.json").exists()
|
||||
|
||||
|
||||
def test_횡단_상세_ETag_같으면_304() -> None:
|
||||
from starlette.requests import Request
|
||||
|
||||
from B06_Section.B06_Section_Router import _etag_json_response
|
||||
|
||||
def request(etag: str | None) -> Request:
|
||||
headers = [] if etag is None else [(b"if-none-match", etag.encode())]
|
||||
return Request({"type": "http", "headers": headers})
|
||||
|
||||
body = b'{"cross_sections": []}'
|
||||
first = _etag_json_response(request(None), body)
|
||||
assert first.status_code == 200 and first.body == body
|
||||
etag = first.headers["etag"]
|
||||
assert _etag_json_response(request(etag), body).status_code == 304
|
||||
assert _etag_json_response(request(etag), body + b" ").status_code == 200
|
||||
|
||||
|
||||
def test_횡단_상세_GET_소스에_지우기_없음() -> None:
|
||||
source = Path("B06_Section/B06_Section_Router.py").read_text(encoding="utf-8")
|
||||
start = source.index("def _read_section_detail(")
|
||||
end = source.index("\ndef ", start + 10)
|
||||
assert "prune_stale_cross_files" not in source[start:end]
|
||||
assert "unlink" not in source[start:end]
|
||||
Reference in New Issue
Block a user