perf(배수유역): 342-3 세부 유역 저장본 — 열쇠가 같으면 다시 나누지 않음(부록 4 R15)

- 열쇠 = 관 목록 · 노선(평면 · 종단 Z) · 분석 산출물(03 road_routing · 01 primary_region) · 강우표 · 구조물 정의 자국
- GET · POST · DELETE pipe-points = 메모리 최근 4벌 + 파일 한 벌(drainage/04_detail_cache.pkl · [저장] 때 GET 열쇠로 씀)
- [저장](PUT)은 늘 정본으로 다시 셈 · 관 목록은 사본으로 돌려줌
- 시험 test_342_3_detail_cache(같은 열쇠 재사용 · 입력 바뀌면 다시 셈 · 다시 켜도 파일로 읽힘)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
This commit is contained in:
2026-10-16 11:13:24 +09:00
co-authored by Claude Opus 5.5
parent e503189cb8
commit fba1eb8a5d
3 changed files with 172 additions and 5 deletions
+94 -5
View File
@@ -15,16 +15,21 @@
from __future__ import annotations
import asyncio
import copy
import hashlib
import json
import logging
import math
import os
import pickle
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import STAGES, drainage_dir
from B05_Profile.B05_Profile_Structures_Schema import DEFINITION_PATH, structure_type_map
from B06_Section.B06_Section_Engine_Culvert import pipe_wall_min_height_m
from common_util.common_util_auth import get_optional_session
from common_util.common_util_drainage_context import DrainageContext, load_drainage_context
@@ -52,6 +57,7 @@ from common_util.common_util_route_geometry import StructureCandidate
from config.config_system import (
DRAINAGE_PIPE_MAX_SPACING_M,
DRAINAGE_PIPE_MIN_SPACING_M,
DRAINAGE_RAINFALL_FILENAME,
FORD_MANNING_N,
FORD_SLOPE,
)
@@ -115,6 +121,83 @@ def _build_stale(context: DrainageContext, points: list[PipePoint]) -> DrainageD
return detail
# 세부 유역 저장본(342-3 · 부록 4 R15) — 열쇠 = 관 목록 · 노선(평면 · 종단 Z) · 분석 산출물 ·
# 강우표 · 구조물 정의 자국. 같으면 다시 나누지 않는다 — 메모리 최근 몇 벌 + [저장] 때 파일 한 벌.
_DETAIL_CACHE: dict[str, tuple[DrainageDetail, list[PipePoint]]] = {}
_DETAIL_CACHE_SIZE = 4
DETAIL_CACHE_FILENAME = "04_detail_cache.pkl"
def _detail_key(context: DrainageContext, points: list[PipePoint] | None) -> str:
directory = drainage_dir(context.stored_path)
digest = hashlib.sha1(usedforsecurity=False)
listed = None if points is None else [point.as_dict() for point in points]
digest.update(json.dumps(listed, sort_keys=True, ensure_ascii=False).encode())
for vertex in context.vertices:
digest.update(f"{vertex.x:.3f},{vertex.y:.3f},{vertex.z:.3f};".encode())
routing = STAGES["road_routing"]
for path in (
directory / f"{routing}_road_routing.npz",
directory / f"{routing}_road_routing.geojson",
directory / f"{STAGES['primary_region']}_primary_region.geojson",
directory / DRAINAGE_RAINFALL_FILENAME,
DEFINITION_PATH,
):
try:
info = os.stat(path)
digest.update(f"{info.st_mtime_ns}:{info.st_size};".encode())
except OSError:
digest.update(b"-;")
return digest.hexdigest()
def _cache_path(stored_path: str):
return drainage_dir(stored_path) / DETAIL_CACHE_FILENAME
def _remember(key: str, value: tuple[DrainageDetail, list[PipePoint]]) -> None:
_DETAIL_CACHE.pop(key, None)
_DETAIL_CACHE[key] = value
while len(_DETAIL_CACHE) > _DETAIL_CACHE_SIZE:
_DETAIL_CACHE.pop(next(iter(_DETAIL_CACHE)))
def _build_cached(
context: DrainageContext, points: list[PipePoint] | None
) -> tuple[DrainageDetail | None, list[PipePoint]]:
"""`_build` + 저장본 — 관 목록은 사본으로 돌려준다(저장이 좌표를 채워도 저장본은 그대로)."""
key = _detail_key(context, points)
hit = _DETAIL_CACHE.get(key)
if hit is None:
try:
with _cache_path(context.stored_path).open("rb") as file:
saved = pickle.load(file)
hit = saved["value"] if saved.get("key") == key else None
except (OSError, pickle.PickleError, EOFError, AttributeError, KeyError, TypeError):
hit = None
if hit is None:
detail, carried = _build(context.stored_path, context, points)
if detail is None:
return None, []
hit = (detail, carried)
_remember(key, hit)
return hit[0], copy.deepcopy(hit[1])
def _save_detail_cache(
context: DrainageContext, points: list[PipePoint] | None, detail: DrainageDetail, carried
) -> None:
"""[저장] 뒤 — 다음 GET 이 읽을 관 목록(저장분) 열쇠로 파일 한 벌을 남긴다."""
key = _detail_key(context, points)
value = (detail, copy.deepcopy(carried))
_remember(key, value)
try:
with _cache_path(context.stored_path).open("wb") as file:
pickle.dump({"key": key, "value": value}, file)
except OSError:
logger.warning("배수유역: 세부 유역 저장본을 남기지 못했습니다(%s).", context.stored_path)
# 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1) · 유입 흙막이만 + 0.7(211-1)
PIPE_HEIGHT_KEYS = ("inlet_revet_height_m", "outlet_revet_height_m", "inlet_basin_height_m")
@@ -352,7 +435,7 @@ def _basin_features(
async def _resolve(
project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool
project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool, fresh: bool = False
) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool, bool] | JSONResponse:
"""요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다.
@@ -385,7 +468,11 @@ async def _resolve(
detail = _build_stale(context, points)
return context, detail, carry_facility_attributes(_rebuilt(detail), points), saved, True
result = await asyncio.to_thread(_build, context.stored_path, context, points)
# [저장]은 정본으로 다시 셈 — 저장본은 GET · POST 만
if fresh:
result = await asyncio.to_thread(_build, context.stored_path, context, points)
else:
result = await asyncio.to_thread(_build_cached, context, points)
detail, resolved = result
if detail is None:
return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS})
@@ -444,7 +531,7 @@ async def put_pipe_points(
session: dict[str, Any] | None = Depends(get_optional_session), # 가져온이(142-2)
) -> dict[str, Any] | JSONResponse:
"""관 지점을 정본으로 확정하고 세부유역 산출물까지 함께 남긴다(모델 확정 시점)."""
resolved = await _resolve(project_id, payload, use_stored=True)
resolved = await _resolve(project_id, payload, use_stored=True, fresh=True)
if isinstance(resolved, JSONResponse):
return resolved
context, detail, points, _, stale = resolved
@@ -461,6 +548,8 @@ async def put_pipe_points(
_basin_features(context, detail, points),
context.crs,
)
stored = load_pipe_points(context.stored_path, signature, context.vertices)
await asyncio.to_thread(_save_detail_cache, context, stored, detail, points)
# 지정한 집계 항목을 프로젝트에 복사 · 안 쓰는 복사본 지움(PLAN 21-3 · 실패해도 저장 유지)
from common_util.common_util_storage import resolve_stored_project_path
from M02_MasterTemplete.M02_Item_Snapshot import importer, safe_sync
@@ -0,0 +1,75 @@
"""342-3 세부 유역 저장본(부록 4 R15) — 열쇠가 같으면 다시 나누지 않음.
- 열쇠 = 관 목록 · 노선(평면 · 종단 Z) · 분석 산출물 · 강우표 · 구조물 정의 자국.
- 같은 열쇠 = _build 안 부름 · 관 목록은 사본 · 무엇이든 바뀌면 다시 셈.
- [저장] 뒤 파일 한 벌 — 서버를 다시 켜도(메모리 비움) GET 열쇠로 읽힘.
"""
import os
from pathlib import Path
from types import SimpleNamespace
import pytest
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
from common_util.common_util_drainage_context import DrainageContext
from common_util.common_util_drainage_detail import DrainageDetail
from common_util.common_util_drainage_pipes import PipePoint
from common_util.common_util_route_geometry import RouteVertex
@pytest.fixture
def setup(tmp_path, monkeypatch):
monkeypatch.setattr(basins, "drainage_dir", lambda stored: tmp_path)
basins._DETAIL_CACHE.clear()
calls = SimpleNamespace(count=0)
def build(stored_path, context, points):
calls.count += 1
chainages = [p.chainage_m for p in points] if points else [50.0]
return DrainageDetail(), [PipePoint(chainage_m=c, source="stream") for c in chainages]
monkeypatch.setattr(basins, "_build", build)
(tmp_path / "03_road_routing.npz").write_bytes(b"grid")
context = DrainageContext(
stored_path="x",
project_root=Path("."),
vertices=[RouteVertex(0.0, 0.0, 100.0, 0.0), RouteVertex(300.0, 0.0, 90.0, 300.0)],
)
return tmp_path, context, calls
def test_same_key_reuses_and_copies(setup):
_, context, calls = setup
points = [PipePoint(chainage_m=120.0, source="user")]
_, first = basins._build_cached(context, points)
first[0].x = 999.0 # 저장이 좌표를 채워도 저장본은 그대로
_, second = basins._build_cached(context, points)
assert calls.count == 1
assert second[0].x is None
def test_any_input_change_recomputes(setup):
tmp_path, context, calls = setup
points = [PipePoint(chainage_m=120.0, source="user")]
basins._build_cached(context, points)
basins._build_cached(context, [PipePoint(chainage_m=121.0, source="user")]) # 관 자리
context.vertices[1].z = 91.0 # 계획고(종단 Z) → 관 깊이
basins._build_cached(context, points)
routing = tmp_path / "03_road_routing.npz"
routing.write_bytes(b"grid-2") # 분석 다시 돎
os.utime(routing, ns=(1, 1))
basins._build_cached(context, points)
assert calls.count == 4
def test_saved_file_survives_restart(setup):
tmp_path, context, calls = setup
stored = [PipePoint(chainage_m=120.0, source="stream")]
detail, carried = basins._build(context.stored_path, context, stored)
basins._save_detail_cache(context, stored, detail, carried)
assert (tmp_path / basins.DETAIL_CACHE_FILENAME).exists()
basins._DETAIL_CACHE.clear() # 서버 다시 켬
_, again = basins._build_cached(context, stored)
assert calls.count == 1
assert [p.chainage_m for p in again] == [120.0]
@@ -57,6 +57,9 @@ def _patch(monkeypatch, analysis_signature, stored):
return SimpleNamespace(pipes=[], basins=["유역"]), points or []
monkeypatch.setattr(basins, "_build", build)
monkeypatch.setattr(
basins, "_build_cached", lambda context, points: build("x", context, points)
)
return built