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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user