diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index ff7705355..867b3dd55 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -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 diff --git a/resources/tester/test_342_3_detail_cache.py b/resources/tester/test_342_3_detail_cache.py new file mode 100644 index 000000000..8ea80935b --- /dev/null +++ b/resources/tester/test_342_3_detail_cache.py @@ -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] diff --git a/resources/tester/test_342_3_drainage_stale.py b/resources/tester/test_342_3_drainage_stale.py index 27fac0b7d..18d096861 100644 --- a/resources/tester/test_342_3_drainage_stale.py +++ b/resources/tester/test_342_3_drainage_stale.py @@ -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